Python Automation Course

Python automation is the practice of turning a repeated computer task into a script you can run again without repeating every click. This free course starts with no programming assumptions and ends with a portfolio-ready reporting tool. You will organize files, clean CSV data, call a web API, add safety controls, schedule a script, and learn how to package the result for a real client.

You do not need a paid course, a powerful computer, or an AI subscription. The core projects use Python’s standard library. You need a computer on which you can install Python, a text editor, and time to practice.

Last reviewed: 9 August 2026 · Level: Complete beginner · Estimated study time: 20–30 focused hours · Cost: Free

Python automation workflow from files and CSV data to organized folders, an API result, and a report
A beginner automation workflow: collect inputs, apply Python rules, organize data, call an API, and produce a report.

What you will be able to do

By the end of the course, you should be able to:

  • break a repetitive task into input, rules, output, and failure cases;
  • write small Python programs using variables, conditions, loops, functions, lists, and dictionaries;
  • work safely with folders and files using pathlib and shutil;
  • read, validate, clean, and write CSV data;
  • fetch public JSON data from an API and handle network errors;
  • turn a script into a command-line tool with logs and a dry-run mode;
  • test an automation on sample data before it touches real files;
  • present a finished automation as a portfolio case study; and
  • scope small automation services without promising results you cannot guarantee.

How this course works

Reading code is not the same as learning to automate. For each lesson, type the example, change one part, break it deliberately, and repair it. The projects are arranged in this order for a reason:

  1. Files: visible input and output make mistakes easy to understand.
  2. CSV data: you learn validation before working with less predictable data.
  3. Web APIs: you add networks, timeouts, rate limits, and external failures.
  4. Command-line tools: you make the script reusable by someone other than its author.
  5. Scheduled work: you learn why unattended automation needs logs and safe defaults.

Keep every project in a separate folder. At the end, those folders become the beginning of your portfolio.

Lesson 0: Set up a clean Python workspace

This course targets Python 3.14. Python 3.14 is the current stable feature series as of this review, but the examples deliberately use long-established standard-library features and should also work on recent Python 3 releases.

Install Python

  1. Download Python from the official Python downloads page.
  2. On Windows, select the installer option that adds Python to your path if it is offered.
  3. Open PowerShell, Terminal, or your system’s command prompt.
  4. Check the installation:
python --version

On some Windows systems, use py --version. On some Linux or macOS systems, use python3 --version. Use whichever command reports Python 3.14 or another supported Python 3 version, and keep that command consistent in the examples below.

Create the course folder and virtual environment

mkdir python-automation-course
cd python-automation-course
python -m venv .venv

Activate it on Windows PowerShell:

.\.venv\Scripts\Activate.ps1

Activate it on macOS or Linux:

source .venv/bin/activate

A virtual environment keeps each project’s packages separate. These projects do not require third-party packages, but learning this habit now prevents version conflicts later.

Your first script

Create hello_automation.py:

task_name = "organize downloads"
minutes_saved_each_run = 8
runs_per_month = 12

monthly_minutes_saved = minutes_saved_each_run * runs_per_month

print(f"Task: {task_name}")
print(f"Estimated time saved per month: {monthly_minutes_saved} minutes")

Run it:

python hello_automation.py

The estimate is yours, not a universal productivity claim. Automation work improves when you measure the actual manual process instead of inventing a dramatic number.

Checkpoint

Change the task name, minutes, and run count. Add a new line that calculates annual minutes. If the result is wrong, print each input before changing the formula.

Lesson 1: Think in inputs, rules, outputs, and failures

Most beginner tutorials start with syntax. Automation starts one step earlier: define the job. A useful automation brief fits on one page.

QuestionFile-organizer example
What is the input?Files in a test inbox folder
What are the rules?Group files by extension
What is the output?Files moved into named subfolders
What may fail?Duplicate names, locked files, missing folder, no permission
How do we undo it?Test copy, dry run, and movement log

The key skill is not writing a loop. It is deciding what the loop is allowed to touch.

The small amount of Python you need first

file_names = ["invoice.pdf", "photo.jpg", "notes.txt", "budget.csv"]

groups = {
    ".pdf": "documents",
    ".jpg": "images",
    ".txt": "text",
    ".csv": "data",
}

for file_name in file_names:
    matched_group = "other"

    for extension, folder in groups.items():
        if file_name.lower().endswith(extension):
            matched_group = folder
            break

    print(file_name, "->", matched_group)

This example uses a list for the inputs, a dictionary for the rules, loops to inspect the data, a condition to make a decision, and a variable to store the result. That is enough to model a surprising number of office tasks.

Exercise: write an automation brief

Choose one task you repeat. Do not code it yet. Write the five answers from the table. Good first tasks include renaming downloaded invoices, validating a contact list, creating a weekly text report, or checking a set of public URLs.

Avoid automating a task that deletes originals, sends messages, changes money, or edits production data as your first project.

Lesson 2: Work with paths and files safely

Python’s pathlib module represents filesystem paths as objects. It is easier to read than manually joining strings with slashes, and it handles Windows and Unix-style path rules for you.

from pathlib import Path

inbox = Path("practice_inbox")
inbox.mkdir(exist_ok=True)

for file_path in inbox.iterdir():
    if file_path.is_file():
        print(file_path.name, file_path.suffix.lower())

Path("practice_inbox") is relative to the folder from which you run the script. During practice, that is safer than pointing at your real Downloads folder.

Mini project 1: a file organizer with dry-run mode

Create a folder named practice_inbox. Put copies of a few harmless files inside it. Then save this as organize_files.py:

from pathlib import Path
import shutil

SOURCE = Path("practice_inbox")
DRY_RUN = True

FOLDERS_BY_EXTENSION = {
    ".csv": "data",
    ".docx": "documents",
    ".jpg": "images",
    ".jpeg": "images",
    ".pdf": "documents",
    ".png": "images",
    ".txt": "text",
}


def unique_destination(destination: Path) -> Path:
    """Return a path that will not overwrite an existing file."""
    if not destination.exists():
        return destination

    counter = 1
    while True:
        candidate = destination.with_name(
            f"{destination.stem}_{counter}{destination.suffix}"
        )
        if not candidate.exists():
            return candidate
        counter += 1


def organize(source: Path, dry_run: bool = True) -> None:
    if not source.exists():
        raise FileNotFoundError(f"Folder does not exist: {source.resolve()}")

    for item in source.iterdir():
        if not item.is_file():
            continue

        folder_name = FOLDERS_BY_EXTENSION.get(item.suffix.lower(), "other")
        destination_folder = source / folder_name
        destination = unique_destination(destination_folder / item.name)

        print(f"{'WOULD MOVE' if dry_run else 'MOVING'}: {item} -> {destination}")

        if not dry_run:
            destination_folder.mkdir(exist_ok=True)
            shutil.move(str(item), str(destination))


if __name__ == "__main__":
    organize(SOURCE, dry_run=DRY_RUN)

Run it once with DRY_RUN = True. Read every proposed move. Only then switch it to False and run it on the practice folder.

Why this version is safer than the usual tutorial script

  • It ignores subfolders, so it does not reorganize its own output.
  • It checks that the source exists before doing work.
  • Dry-run mode shows the plan without changing files.
  • It creates destination folders only when a real move happens.
  • It generates a new filename instead of silently overwriting a duplicate.
  • The moving logic is inside a function, which makes later testing easier.

Practice upgrades

  1. Add .xlsx and .webp rules.
  2. Create a rule that sends unknown extensions to other.
  3. Skip hidden files whose names begin with a dot.
  4. Write each completed move to moves.log.
  5. Replace the extension rules with year-and-month rules based on file modification time.

Lesson 3: Clean CSV data without hiding bad rows

CSV automation is paid work in disguise. Small organizations export customer lists, inventory, expenses, survey results, and order histories as CSV files. The hard part is not reading rows. It is deciding what counts as valid, keeping rejected data visible, and preserving the original.

Create contacts.csv:

name,email,city
  Ayesha Khan  ,AYESHA@example.com,karachi
Bilal Ahmed,bilal@example.com,Lahore
No Email,,Islamabad
Duplicate Ayesha,ayesha@example.com,Karachi

Save this as clean_contacts.py:

from pathlib import Path
import csv

INPUT_FILE = Path("contacts.csv")
CLEAN_FILE = Path("contacts_clean.csv")
REJECTED_FILE = Path("contacts_rejected.csv")


def normalize_name(value: str) -> str:
    return " ".join(value.strip().split()).title()


def normalize_city(value: str) -> str:
    return " ".join(value.strip().split()).title()


def normalize_email(value: str) -> str:
    return value.strip().lower()


def looks_like_email(value: str) -> bool:
    local, separator, domain = value.partition("@")
    return bool(local and separator and "." in domain)


def clean_contacts(input_file: Path) -> tuple[int, int]:
    accepted = []
    rejected = []
    seen_emails = set()

    with input_file.open("r", encoding="utf-8-sig", newline="") as source:
        reader = csv.DictReader(source)
        required = {"name", "email", "city"}

        if not reader.fieldnames or not required.issubset(reader.fieldnames):
            raise ValueError(f"CSV must contain these columns: {sorted(required)}")

        for row_number, row in enumerate(reader, start=2):
            clean_row = {
                "name": normalize_name(row["name"]),
                "email": normalize_email(row["email"]),
                "city": normalize_city(row["city"]),
            }

            reason = ""
            if not clean_row["name"]:
                reason = "missing name"
            elif not looks_like_email(clean_row["email"]):
                reason = "invalid email"
            elif clean_row["email"] in seen_emails:
                reason = "duplicate email"

            if reason:
                rejected.append({**clean_row, "row_number": row_number, "reason": reason})
                continue

            seen_emails.add(clean_row["email"])
            accepted.append(clean_row)

    with CLEAN_FILE.open("w", encoding="utf-8", newline="") as destination:
        writer = csv.DictWriter(destination, fieldnames=["name", "email", "city"])
        writer.writeheader()
        writer.writerows(accepted)

    with REJECTED_FILE.open("w", encoding="utf-8", newline="") as destination:
        fields = ["name", "email", "city", "row_number", "reason"]
        writer = csv.DictWriter(destination, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rejected)

    return len(accepted), len(rejected)


if __name__ == "__main__":
    accepted_count, rejected_count = clean_contacts(INPUT_FILE)
    print(f"Accepted: {accepted_count}")
    print(f"Rejected: {rejected_count}")

The rejected file is not a failure. It is an audit trail. A client can correct those rows instead of wondering why records disappeared.

What this validator does not prove

The looks_like_email function catches obvious mistakes. It does not prove that an address exists, belongs to the person named, or can receive mail. Good automation labels its certainty accurately.

Checkpoint

  • What happens if the CSV uses Email instead of email?
  • Why do we write a new clean file instead of editing the source?
  • Why is utf-8-sig useful for some CSV exports?
  • How would you validate a required phone number without claiming that it is active?

Lesson 4: Fetch JSON from a public API

Automation becomes much more useful when it can combine local data with a service. An API gives a program a documented way to request or change data. This lesson reads public repository information from GitHub. It does not need a token, although GitHub limits unauthenticated requests.

Save this as github_repo_report.py:

from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
import json

API_URL = "https://api.github.com/repos/python/cpython"
OUTPUT_FILE = Path("cpython_report.txt")


def fetch_repository(url: str) -> dict:
    request = Request(
        url,
        headers={
            "Accept": "application/vnd.github+json",
            "User-Agent": "metacyberguru-python-course",
            "X-GitHub-Api-Version": "2022-11-28",
        },
    )

    with urlopen(request, timeout=15) as response:
        return json.load(response)


def build_report(repository: dict) -> str:
    checked_at = datetime.now(timezone.utc).isoformat()
    return "\n".join(
        [
            f"Repository: {repository['full_name']}",
            f"Description: {repository.get('description') or 'No description'}",
            f"Open issues: {repository['open_issues_count']}",
            f"Default branch: {repository['default_branch']}",
            f"Checked at: {checked_at}",
        ]
    )


def main() -> None:
    try:
        repository = fetch_repository(API_URL)
        report = build_report(repository)
        OUTPUT_FILE.write_text(report + "\n", encoding="utf-8")
        print(f"Saved report to {OUTPUT_FILE.resolve()}")
    except HTTPError as error:
        remaining = error.headers.get("x-ratelimit-remaining", "unknown")
        print(f"GitHub returned HTTP {error.code}. Rate limit remaining: {remaining}")
    except URLError as error:
        print(f"Network error: {error.reason}")
    except (KeyError, TypeError, json.JSONDecodeError) as error:
        print(f"Unexpected response format: {error}")


if __name__ == "__main__":
    main()

Why the boring details matter

  • Timeout: without one, a network call may appear to hang.
  • User-Agent: the service can identify the client making the request.
  • Version header: your code is explicit about the GitHub API version it expects.
  • Error types: an HTTP response, a connection failure, and malformed JSON are different problems.
  • UTC timestamp: reports made on different computers remain comparable.

GitHub’s documented primary limit for unauthenticated REST requests is 60 requests per hour per originating IP address. Do not put this script in a loop that calls the API every second. Real API clients should also inspect rate-limit headers and avoid unnecessary polling.

Practice upgrades

  1. Accept an owner and repository name instead of hard-coding CPython.
  2. Add the repository’s license name when it is available.
  3. Write JSON as well as text output.
  4. Compare two repositories, but explain why raw stars do not prove software quality.

Lesson 5: Turn a script into a reusable command

A script becomes more useful when a user can choose the input without editing the source code. Python’s argparse module creates command-line arguments and automatic help text.

Replace the fixed SOURCE and DRY_RUN values in the file organizer with this entry point:

import argparse


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Organize files into extension-based folders."
    )
    parser.add_argument("source", type=Path, help="Folder containing files to organize")
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Move files. Without this flag, only show the plan.",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    organize(args.source, dry_run=not args.apply)

Now the safe default is a preview:

python organize_files.py practice_inbox

The user must explicitly add --apply to move files:

python organize_files.py practice_inbox --apply

This is a better interface than --dry-run because forgetting a flag cannot trigger the destructive behavior.

Add logs for unattended runs

import logging

logging.basicConfig(
    filename="automation.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

logging.info("Automation started")

Do not log passwords, API tokens, personal records, or full confidential files. Logs should explain what the program did without becoming a new data leak.

Lesson 6: Make the automation safe enough to trust

A working happy path is a prototype. A trustworthy automation also handles repetition, partial failure, bad input, and recovery.

Use this reliability checklist

  • Sample first: test on copied data in a separate folder.
  • Preserve the source: write a new output file unless in-place editing is essential.
  • Dry run: preview changes before applying them.
  • No silent overwrite: stop, version, or rename when the destination exists.
  • Idempotency: running the tool twice should not duplicate work or corrupt output.
  • Validate boundaries: check required columns, allowed extensions, date formats, and destination paths.
  • Handle expected errors: report a locked file differently from a programming bug.
  • Log decisions: record what was accepted, rejected, moved, or skipped.
  • Keep secrets outside code: read tokens from environment variables or an approved secret store.
  • Least privilege: give the script access only to the folder or account it needs.

A small automated test

Save this as test_clean_contacts.py beside clean_contacts.py:

import unittest

from clean_contacts import looks_like_email, normalize_city, normalize_email


class ContactCleanerTests(unittest.TestCase):
    def test_email_is_normalized(self):
        self.assertEqual(normalize_email("  USER@Example.COM "), "user@example.com")

    def test_obvious_bad_email_is_rejected(self):
        self.assertFalse(looks_like_email("not-an-email"))

    def test_city_spacing_is_cleaned(self):
        self.assertEqual(normalize_city("  islamabad  "), "Islamabad")


if __name__ == "__main__":
    unittest.main()

Run the tests:

python -m unittest -v

Three tests do not make the cleaner perfect. They protect three decisions from being accidentally changed later. Add tests when you discover a real edge case.

Final project: build an expense-report automation

The final project turns a raw CSV export into two outputs: a clean rejected-row log and a category summary. It demonstrates file handling, CSV validation, functions, logging, command-line arguments, and safe output behavior.

Project specification

The input CSV must have four columns:

date,description,category,amount
2026-08-01,Domain renewal,Software,14.99
2026-08-02,Client meeting,Travel,8.50
bad-date,Unknown purchase,Other,12.00
2026-08-03,Refund,Software,-5.00

The tool should:

  1. accept the input path from the command line;
  2. validate the required headers;
  3. validate ISO-format dates and decimal amounts;
  4. reject blank descriptions or categories;
  5. calculate totals by category;
  6. write expense_summary.csv and expense_rejected.csv;
  7. refuse to overwrite output unless the user passes --force; and
  8. log a count of accepted and rejected rows.

Save this as expense_report.py:

from collections import defaultdict
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path
import argparse
import csv
import logging

SUMMARY_FILE = Path("expense_summary.csv")
REJECTED_FILE = Path("expense_rejected.csv")
REQUIRED_COLUMNS = {"date", "description", "category", "amount"}

logging.basicConfig(
    filename="expense_report.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Create a category expense report from CSV.")
    parser.add_argument("input", type=Path, help="Source expense CSV")
    parser.add_argument("--force", action="store_true", help="Overwrite existing outputs")
    return parser.parse_args()


def validate_row(row: dict[str, str], row_number: int) -> tuple[dict | None, dict | None]:
    cleaned = {key: (value or "").strip() for key, value in row.items()}

    try:
        parsed_date = date.fromisoformat(cleaned["date"])
    except ValueError:
        return None, {**cleaned, "row_number": row_number, "reason": "invalid date"}

    try:
        amount = Decimal(cleaned["amount"])
    except InvalidOperation:
        return None, {**cleaned, "row_number": row_number, "reason": "invalid amount"}

    if not cleaned["description"]:
        return None, {**cleaned, "row_number": row_number, "reason": "missing description"}

    if not cleaned["category"]:
        return None, {**cleaned, "row_number": row_number, "reason": "missing category"}

    return {
        "date": parsed_date.isoformat(),
        "description": cleaned["description"],
        "category": cleaned["category"].title(),
        "amount": amount,
    }, None


def read_expenses(input_file: Path) -> tuple[list[dict], list[dict]]:
    accepted = []
    rejected = []

    with input_file.open("r", encoding="utf-8-sig", newline="") as source:
        reader = csv.DictReader(source)
        headers = set(reader.fieldnames or [])

        if not REQUIRED_COLUMNS.issubset(headers):
            missing = sorted(REQUIRED_COLUMNS - headers)
            raise ValueError(f"Missing required columns: {missing}")

        for row_number, row in enumerate(reader, start=2):
            good, bad = validate_row(row, row_number)
            if good:
                accepted.append(good)
            if bad:
                rejected.append(bad)

    return accepted, rejected


def ensure_outputs_are_safe(force: bool) -> None:
    existing = [path for path in (SUMMARY_FILE, REJECTED_FILE) if path.exists()]
    if existing and not force:
        names = ", ".join(path.name for path in existing)
        raise FileExistsError(f"Output exists: {names}. Use --force to replace it.")


def write_summary(expenses: list[dict]) -> None:
    totals = defaultdict(Decimal)
    for expense in expenses:
        totals[expense["category"]] += expense["amount"]

    with SUMMARY_FILE.open("w", encoding="utf-8", newline="") as destination:
        writer = csv.DictWriter(destination, fieldnames=["category", "total"])
        writer.writeheader()
        for category in sorted(totals):
            writer.writerow({"category": category, "total": f"{totals[category]:.2f}"})


def write_rejected(rows: list[dict]) -> None:
    fields = ["date", "description", "category", "amount", "row_number", "reason"]
    with REJECTED_FILE.open("w", encoding="utf-8", newline="") as destination:
        writer = csv.DictWriter(destination, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rows)


def main() -> None:
    args = parse_args()

    if not args.input.is_file():
        raise FileNotFoundError(f"Input file not found: {args.input}")

    ensure_outputs_are_safe(args.force)
    accepted, rejected = read_expenses(args.input)
    write_summary(accepted)
    write_rejected(rejected)

    logging.info("Accepted rows: %s; rejected rows: %s", len(accepted), len(rejected))
    print(f"Accepted rows: {len(accepted)}")
    print(f"Rejected rows: {len(rejected)}")
    print(f"Summary: {SUMMARY_FILE.resolve()}")
    print(f"Rejected rows: {REJECTED_FILE.resolve()}")


if __name__ == "__main__":
    main()

Run it:

python expense_report.py expenses.csv

If you have checked the old output and intend to replace it:

python expense_report.py expenses.csv --force

Final-project acceptance test

Do not call the project finished until all of these are true:

  • A valid four-row sample produces the expected category totals.
  • An invalid date goes to the rejected file with its source row number.
  • An invalid amount does not crash the whole run.
  • A missing required column stops before writing output.
  • Existing output is preserved unless --force is present.
  • Negative amounts are handled according to the rule you document.
  • The README explains the input columns, command, outputs, and limitations.

Schedule a script only after it is observable

Scheduling is the last step, not the first. An unattended script needs a fixed working directory, absolute input paths, logs, safe output behavior, and a clear failure signal.

On Windows, Task Scheduler can start the Python executable and pass the full script path as an argument. On Linux and many Unix-like systems, cron can run a command on a schedule. Test the exact command manually from the same account before scheduling it.

A scheduled automation that silently fails is worse than a manual checklist because people stop looking for the missing work.

Build a portfolio that proves the skill

A portfolio entry needs more than a screenshot of code. Show the problem and the evidence that your program handles it.

For each project, include:

  • a short problem statement;
  • sample input containing no real personal or client data;
  • the rules and known limitations;
  • the command needed to run it;
  • sample output;
  • a safety note describing dry runs, backups, and overwrite behavior;
  • tests or an acceptance checklist; and
  • a two-minute screen recording if it makes the workflow easier to understand.

A sensible repository layout looks like this:

expense-report-automation/
├── README.md
├── expense_report.py
├── sample_data/
│   └── expenses.csv
├── sample_output/
│   ├── expense_summary.csv
│   └── expense_rejected.csv
└── tests/
    └── test_expense_report.py

Never publish client data, credentials, private URLs, production logs, or a client’s code without written permission.

Realistic ways this skill can become paid work

Python automation can support paid work, but learning the syntax does not guarantee income. Clients pay for a reliable result attached to a business problem.

ServiceUseful proofMain risk to clarify
CSV cleanup and validationBefore/after sample plus rejected-row reportWhat counts as valid
File renaming and organizationDry-run output and collision handlingBackup and rollback
Recurring report generationSample report and logged runChanging input format
Public API data collectionCached output and error handlingTerms, limits, and API changes
Migration helperMapping rules and test datasetData loss and reversibility

How to scope a small automation job

  1. Ask for a redacted sample input before quoting the work.
  2. Write the rules in plain language and get agreement on ambiguous cases.
  3. Define the output files and what happens to rejected records.
  4. State what the script will not do.
  5. Deliver a dry run or test result before touching live data.
  6. Include setup instructions and a short support boundary.

If you are in Pakistan and need to understand practical payment options for international freelance work, MetaCyberGuru’s guide to how Pakistani freelancers get paid by international clients covers the payment side separately.

Common mistakes that slow beginners down

  • Automating a vague process: if humans disagree on the rule, the code cannot resolve it by magic.
  • Using the real folder first: a test copy is cheaper than file recovery.
  • Hiding rejected data: skipped records need a reason and a path back to review.
  • Copying code without changing it: change the sample input, add a rule, and explain the output in your own words.
  • Adding packages too early: first learn what Python already provides; add a dependency when it makes the solution meaningfully clearer.
  • Putting secrets in the script: source code gets copied, shared, and committed.
  • Scheduling before logging: unattended code must leave evidence.
  • Selling “Python” instead of an outcome: a client understands “clean these exports every Friday” more easily than “I write Python scripts.”

Knowledge check

  1. Why is --apply safer than a --dry-run option?
  2. What is the difference between validating an email’s shape and proving that the mailbox exists?
  3. Why should network requests have a timeout?
  4. What makes an automation idempotent?
  5. Which information should never appear in logs?
  6. What evidence would make a CSV-cleaning portfolio project credible?
Suggested answers
  1. The default behavior makes no change; the user must explicitly request the move.
  2. Shape validation catches obvious formatting errors but does not contact or verify the mailbox.
  3. A timeout prevents a failed or slow service from blocking the program indefinitely.
  4. Repeating it with the same input does not duplicate work or damage the result.
  5. Passwords, tokens, private records, confidential file contents, and unnecessary personal data.
  6. A redacted sample, documented rules, rejected-row output, repeatable command, and tests or acceptance checks.

What to learn next

Do not jump straight from file scripts to a large AI system. Choose the next layer based on the work you want to do:

  • More reliable scripts: learn deeper testing, type hints, packaging, and Git.
  • Business data: learn SQL, spreadsheet formats, and data analysis.
  • Web services: learn HTTP, authentication, API design, and a small web framework.
  • AI tool integrations: continue with MetaCyberGuru’s Model Context Protocol guide for Python tools.
  • Market automation: first study risk and failure handling in the Python trading-bot safety guide. Automation does not remove financial risk.

Free official reference desk

Python fundamentals change slowly, while API behavior and tooling change faster. This course should be reviewed every six months and whenever Python enters a new stable feature series or GitHub changes the API behavior used by the project.