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

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
pathlibandshutil; - 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:
- Files: visible input and output make mistakes easy to understand.
- CSV data: you learn validation before working with less predictable data.
- Web APIs: you add networks, timeouts, rate limits, and external failures.
- Command-line tools: you make the script reusable by someone other than its author.
- 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
- Download Python from the official Python downloads page.
- On Windows, select the installer option that adds Python to your path if it is offered.
- Open PowerShell, Terminal, or your system’s command prompt.
- 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.
| Question | File-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
- Add
.xlsxand.webprules. - Create a rule that sends unknown extensions to
other. - Skip hidden files whose names begin with a dot.
- Write each completed move to
moves.log. - 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
Emailinstead ofemail? - Why do we write a new clean file instead of editing the source?
- Why is
utf-8-siguseful 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
- Accept an owner and repository name instead of hard-coding CPython.
- Add the repository’s license name when it is available.
- Write JSON as well as text output.
- 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:
- accept the input path from the command line;
- validate the required headers;
- validate ISO-format dates and decimal amounts;
- reject blank descriptions or categories;
- calculate totals by category;
- write
expense_summary.csvandexpense_rejected.csv; - refuse to overwrite output unless the user passes
--force; and - 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
--forceis 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.
| Service | Useful proof | Main risk to clarify |
|---|---|---|
| CSV cleanup and validation | Before/after sample plus rejected-row report | What counts as valid |
| File renaming and organization | Dry-run output and collision handling | Backup and rollback |
| Recurring report generation | Sample report and logged run | Changing input format |
| Public API data collection | Cached output and error handling | Terms, limits, and API changes |
| Migration helper | Mapping rules and test dataset | Data loss and reversibility |
How to scope a small automation job
- Ask for a redacted sample input before quoting the work.
- Write the rules in plain language and get agreement on ambiguous cases.
- Define the output files and what happens to rejected records.
- State what the script will not do.
- Deliver a dry run or test result before touching live data.
- 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
- Why is
--applysafer than a--dry-runoption? - What is the difference between validating an email’s shape and proving that the mailbox exists?
- Why should network requests have a timeout?
- What makes an automation idempotent?
- Which information should never appear in logs?
- What evidence would make a CSV-cleaning portfolio project credible?
Suggested answers
- The default behavior makes no change; the user must explicitly request the move.
- Shape validation catches obvious formatting errors but does not contact or verify the mailbox.
- A timeout prevents a failed or slow service from blocking the program indefinitely.
- Repeating it with the same input does not duplicate work or damage the result.
- Passwords, tokens, private records, confidential file contents, and unnecessary personal data.
- 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
- The Python Tutorial
- Virtual Environments and Packages
pathlibdocumentationshutildocumentationcsvdocumentationargparsedocumentationloggingdocumentationunittestdocumentation- GitHub REST API: getting started
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.