Skip to content
DevOps AI ToolKit
Newsletter

GitHub AI Engineering Academy · Part 9 of 16

GitHub Copilot for Python: AI-Powered Automation for DevOps and AI Engineers

Level: Intermediate Copilot ~28 min Part 9/16
Academy progress9 / 16
Academy curriculum (16 lessons)

Bash orchestrates; Python builds. The moment DevOps work grows past chaining commands — nested JSON from a cloud API, a REST client with retries, a reusable library a team maintains for years, or an integration with an AI model — Python is the language that takes over. GitHub Copilot is unusually strong here: Python is stable, idiomatic, and everywhere in its training data, so Copilot drafts functions, type hints, API clients, tests, and CLIs quickly and usually well. But “usually well” is not “finished,” and Python’s reach — subprocess, the filesystem, the network, infrastructure APIs, AI providers — means a plausible-looking draft can leak a secret, hang forever, or run an injected command.

This is Part 9 of the GitHub AI Engineering Academy. Part 8, GitHub Copilot for Bash, ended exactly where this lesson begins: Bash is great for shell orchestration, CI glue, and simple system tasks; Python wins for APIs, structured data, reusable libraries, concurrency, testing, and AI integrations. Part 3, GitHub Copilot CLI, introduced the agentic copilot command, and Part 4, GitHub Copilot in VS Code, covered inline completions and Copilot Chat — the surfaces you will use to generate the Python in this lesson. One frame runs through all of it: Copilot proposes → you read and understand → deterministic tooling validates → tests pass → an engineer reviews → then it deploys.

Python sits at the intersection of the work modern platform and AI engineers actually do: infrastructure automation, cloud and Kubernetes control, GitHub API automation, monitoring and reporting, REST clients, JSON and YAML processing, AI SDKs, and LLM-backed applications and CLIs. Copilot accelerates the writing across every one of those. What it does not do is decide whether the code is correct, safe, or dependency-clean for your systems. That decision comes from static analysis, type checking, unit and integration tests, a security review, and a human. Picture the pipeline before we start:

Requirement
     |
   Copilot
     |
   Python
     |
Static Analysis (Ruff / mypy)
     |
 Unit Tests (pytest)
     |
Integration Tests
     |
Engineer Review   <-- required
     |
  Deployment

Copilot assists at the writing step. Everything below it — the linting, the typing, the tests, the review — is how a draft becomes something you can run against real infrastructure.

What You’ll Learn

  • Where Python fits in an AI-heavy DevOps world, and the honest handoff from Bash covered in Part 8.
  • Writing a first Python automation script with Copilot — a REST-to-JSON service check — and hardening the naive draft with timeouts, exceptions, logging, type hints, config, CLI args, and tests.
  • Functions, type hints, and dataclasses — small, testable, well-typed building blocks without over-engineering.
  • REST APIs done safely — httpx/requests with explicit timeouts, status handling, env-var auth, and bounded retries.
  • Secrets, exceptions, and logging — env-var secrets with validation, specific exception handling, and the stdlib logging module (never logging tokens).
  • CLIs with argparse, and JSON/YAML processing with yaml.safe_load.
  • Python for Linux, Docker, Kubernetes, GitHub, and infrastructure APIs — subprocess with arg lists (never shell=True), read-only diagnostics, and the GitHub API.
  • AI API integration behind an llm.py abstraction, an AI Log Analyzer example, refactoring with Copilot, pytest with mocked external calls, Ruff/mypy and dependency hygiene, a security review checklist, a GitHub Actions CI workflow, 30 reusable prompts, and a full lab.

Bash vs Python

Part 8 made the case for Bash and flagged where it runs out of room. This lesson picks up the other side of that handoff. The two languages are complementary, not competing: pick the right one per task.

Reach for BashReach for Python
Shell automation and Linux utilitiesComplex control flow and branching
Gluing CLIs together in CIREST APIs with pagination and retries
Machine bootstrap and provisioningNested JSON/YAML parsing and transformation
Simple, short-lived system tasksReusable libraries a team maintains
One-off orchestrationConcurrency and parallelism
Real testing (pytest) and structured logging
Large projects, databases, AI APIs
Long-term maintainability

Bash is the right tool when you are orchestrating other command-line programs and gluing steps together — compact, ubiquitous, no runtime beyond the shell. Python is the right tool the moment the work involves structured data, non-trivial API logic, code that must be tested and maintained, or an AI integration. The transition line from Part 8 holds: if you find yourself simulating data structures in Bash, or writing more than roughly a page of branching logic, stop and reach for Python. The Bash and Python automation guides cover both ends of that spectrum.

🛠️ DevOps Tip — When a task is on the fence, ask Copilot to reason about it: “Should this be a Bash script or a Python module, given it parses JSON and needs retries?” It will usually weigh complexity and maintainability sensibly. You still decide, but the framing catches the Bash script that has quietly grown too clever for the shell.

Create a Python Demo Repository

Work through concrete examples in a small, modern repository so nothing is abstract:

copilot-python-demo/
  src/
    devops_toolkit/
      __init__.py
      cli.py          argparse entry point
      api.py          REST client (httpx/requests)
      health.py       service-health business logic
      config.py       env-var config + validation
  tests/
    test_health.py    deterministic unit tests
  pyproject.toml      PEP 621 metadata + tooling
  .env.example        placeholder secret names
  .gitignore
  README.md

Each module has one job. config.py reads and validates environment variables so nothing downstream reaches for os.environ directly. api.py owns HTTP — timeouts, headers, status handling — and nothing else. health.py holds the pure business logic (given a list of services, which are failed?), which makes it trivial to test. cli.py parses arguments and wires the pieces together. Tests live under tests/, and pyproject.toml carries the package metadata and the configuration for Ruff, mypy, and pytest in one place.

This is a src layout (src/devops_toolkit/) with modern packaging: PEP 621 metadata in pyproject.toml, an __init__.py marking the package, and no setup.py. The .env.example documents which secrets the tool expects using placeholder names only, while the real .env stays in .gitignore and never enters Git.

✅ Best Practice — Ask Copilot to “scaffold a src-layout Python package with a pyproject.toml configured for Ruff, mypy, and pytest.” Then read what it generated. Copilot sometimes emits legacy setup.py patterns or pins tools you do not want stacked (flake8 and black alongside Ruff, which replaces both). Trim it down to Ruff, mypy, and pytest before you commit.

Generate a First Python Automation Script

Start with a task Copilot handles well. Describe the goal in a comment or Copilot Chat:

“Write a Python script that calls a REST API returning a JSON list of services, prints the ones whose status is failed, and exits non-zero if any failed.”

A first draft typically looks like this:

import requests

def main():
    r = requests.get("https://example.com/api/services")
    services = r.json()
    failed = [s for s in services if s["status"] == "failed"]
    for s in failed:
        print(s["name"])
    if failed:
        exit(1)

main()

This runs against a friendly endpoint, and it is a good example of why “it runs” is not the finish line. Read it critically:

  • No timeout on the request, so a hung endpoint stalls the script forever.
  • No status-code check — an HTTP 500 with an HTML error body sails into .json() and raises an opaque error.
  • No exception handling — a network error, malformed JSON, or a missing status key crashes with a raw traceback and no exit-code discipline.
  • print instead of logging, a hardcoded URL, no type hints, and exit() (a REPL helper) instead of sys.exit().

Ask Copilot to fix the specific problems: “Add an explicit timeout, handle HTTP status and network errors, use logging, add type hints, take the URL and token from config, add argparse, and return a proper exit code.” A stronger version, split across the modules above, appears through the rest of this lesson — starting with the pure logic, which is the part worth testing.

Functions

The most testable thing in that script is the decision “which services are failed?” — so make it a function with a single responsibility, predictable inputs, and a return value instead of a side effect:

def get_failed_services(
    services: list[dict],
) -> list[dict]:
    """Return services whose status is 'failed'.

    Pure function: no I/O, no printing, no exit.
    """
    return [
        s for s in services
        if s.get("status") == "failed"
    ]

Why this shape matters:

  • Single responsibility — it filters; it does not fetch, print, or exit. Those belong to other functions.
  • Predictable inputs — it takes a plain list of dicts, so a test can hand it any case without a network.
  • Return values over side effects — it returns data rather than printing, so callers decide what to do with the result.
  • .get("status") rather than s["status"] — a service missing the key is treated as not-failed instead of raising a KeyError. Whether that is the behavior you want is a review decision, but it is now an explicit one.

A function like this is the atom of testable Python: deterministic, dependency-free, and covered by a two-line pytest case. Keep the I/O (the HTTP call, the printing, the exit) at the edges, and the logic pure in the middle.

Type Hints

Type hints make Copilot’s output easier to read, easier to refactor, and checkable by mypy. Use modern, builtin generics — list, dict, X | None — and reach for heavier tools only where they earn their keep:

def parse_services(
    payload: dict,
) -> list[dict]:
    return payload.get("services", [])

def find_service(
    services: list[dict],
    name: str,
) -> dict | None:
    for svc in services:
        if svc.get("name") == name:
            return svc
    return None

list[dict] and dict[str, int] are builtin generics (no typing.List import needed on modern Python), and dict | None is the modern union that says “a dict or nothing.” For a small, well-defined record, a dataclass (next section) is often clearer than a bare dict. TypedDict and Pydantic are worth it when you are validating external data against a real schema — but do not reach for them on every function.

The benefit is concrete: the editor autocompletes fields, mypy catches a str passed where a list[dict] was expected before the code ever runs, refactoring tools rename safely, and the next reader sees the shape of the data without running it.

✅ Best Practice — Ask Copilot to “add useful type hints without overcomplicating this.” It is good at annotating function signatures. Reject the version that drags in Pydantic models for a three-line internal helper — types should clarify, not bury the logic under a schema you do not need.

Dataclasses and Structured Data

When a dict is passed around and its keys are accessed by hand in several places, a dataclass makes the shape explicit and gives you attribute access, a readable repr, and a natural place for small methods:

from dataclasses import dataclass

@dataclass
class ServiceStatus:
    name: str
    status: str
    restarts: int = 0

    @property
    def is_failed(self) -> bool:
        return self.status == "failed"

Now svc.name replaces svc["name"], a typo becomes an AttributeError at the point of use instead of a silent None, and svc.is_failed centralizes the rule. Construct them from parsed JSON at the boundary:

def to_statuses(
    services: list[dict],
) -> list[ServiceStatus]:
    return [
        ServiceStatus(
            name=s["name"],
            status=s["status"],
            restarts=s.get("restarts", 0),
        )
        for s in services
    ]

When a dataclass is worth it: the data has a fixed, known shape; it is passed through several functions; or you want methods and validation attached to it. When a plain dict suffices: you are reading a value once and moving on, or the structure is genuinely dynamic. Do not wrap every JSON blob in a dataclass reflexively — but a recurring record like ServiceStatus almost always reads better as one.

REST APIs

REST clients are where Copilot-generated Python most often ships a latent bug, because the dangerous omissions — a missing timeout, an unchecked status — do not fail on the happy path. Use httpx or requests, and put an explicit timeout= on every call:

import httpx

def get_services(
    url: str,
    token: str,
    timeout: float = 10.0,
) -> list[dict]:
    headers = {"Authorization": f"Bearer {token}"}
    resp = httpx.get(
        url,
        headers=headers,
        params={"state": "all"},
        timeout=timeout,   # never unbounded
    )
    resp.raise_for_status()   # 4xx/5xx -> exception
    data = resp.json()
    return data.get("services", [])

What each part does and what to check:

  • timeout=timeout — a bounded timeout on every request. An unbounded call against a hung endpoint stalls forever; this is the single most common omission in generated HTTP code.
  • headers with a Bearer token — auth via a value passed in from config, never a literal in the source.
  • params={...} — query parameters passed as a dict so httpx encodes them, rather than string-concatenated into the URL.
  • raise_for_status() — turns a 4xx/5xx into an exception instead of letting an error body flow into .json().
  • .get("services", []) — tolerant parsing that does not KeyError on an unexpected shape.

Retries belong here too, but bounded and only for idempotent, transient failures — a GET, a status poll, a rate-limit backoff — never a blind retry around a request that mutates state. httpx supports transport-level retries; a small backoff loop works as well, capped at a few attempts. The data flow is worth holding in your head:

Python
   |
HTTP client (httpx / requests)
   |
  REST API
   |
   JSON
   |
 Validation (shape / status)
   |
Business logic

⚠️ Warning — Never put a real API key or token in source, in an example, or in a committed file. Copilot will sometimes paste a plausible-looking literal to make a snippet runnable. Replace it with an environment lookup (next section) every time, and confirm no key slipped into a docstring, a test fixture, or a comment before you commit.

Environment Variables and Secrets

Secrets come from the environment and are validated before use — never hardcoded:

import os

def load_token() -> str:
    token = os.environ.get("API_TOKEN")
    if not token:
        raise RuntimeError(
            "API_TOKEN is not set; "
            "export it or add it to your .env"
        )
    return token

os.environ.get("API_TOKEN") reads the value; the explicit check fails loudly with an actionable message instead of sending Authorization: Bearer None and getting a confusing 401. Locally, keep values in a .env file that is listed in .gitignore, and commit a .env.example that documents the names with placeholders only:

# .env.example  (committed, no real values)
API_URL=https://example.com/api/services
API_TOKEN=replace-me

In CI and production, the same os.environ.get reads a value injected by GitHub Actions secrets (${{ secrets.API_TOKEN }}) or a secret manager — the code does not change, only where the value comes from. Never commit a populated .env, never pass a secret as a command-line argument (it shows up in ps), and never print one.

Exception Handling

The worst pattern Copilot occasionally emits is the silent catch-all:

# BAD: hides every failure, including bugs
try:
    services = get_services(url, token)
except Exception:
    pass

That swallows network errors, JSON errors, and programming mistakes alike, leaving a tool that “succeeds” while doing nothing. Catch specific exceptions, produce an actionable message, preserve the traceback for the unexpected, and exit with a meaningful code:

import logging
import sys
import httpx

log = logging.getLogger(__name__)

def fetch_or_exit(url: str, token: str) -> list[dict]:
    try:
        return get_services(url, token)
    except httpx.TimeoutException:
        log.error("request to %s timed out", url)
        sys.exit(2)
    except httpx.HTTPStatusError as exc:
        log.error(
            "API returned %s for %s",
            exc.response.status_code, url,
        )
        sys.exit(3)
    except httpx.RequestError as exc:
        log.error("network error calling %s: %s", url, exc)
        sys.exit(4)

Each except names a real, expected failure and maps it to a distinct exit code, so a caller — a CI step, a cron wrapper — can tell a timeout from an auth failure. Anything genuinely unexpected is not caught here; it propagates with its full traceback so you can debug it, rather than being flattened into pass. The principle: make failures observable without catching too broadly.

Logging

Use the stdlib logging module rather than print, so output has levels, timestamps, and a destination you control:

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger(__name__)

log.debug("fetched %d services", len(services))
log.info("healthy: all services running")
log.warning("2 services approaching restart limit")
log.error("3 services failed")

DEBUG is verbose diagnostics, INFO is normal progress, WARNING is a concerning-but-handled condition, and ERROR is a failure. Configure the level once (from an env var or --verbose flag) so the same code is quiet in production and chatty when you are debugging, without editing print statements in and out.

⚠️ Warning — Never log secrets. Do not log tokens, Authorization headers, full request/response bodies that may contain credentials, or other sensitive payloads. A logged Authorization: Bearer ... line ends up in your log aggregator forever. Review Copilot-generated logging for a stray log.debug("headers: %s", headers) — it will happily add one — and redact or remove it before the code ships.

CLI Applications with argparse

Turn a script into a proper tool with argparse: named arguments, defaults, help text, validation, and exit codes. Run it as a module — python -m devops_toolkit.cli --url ... --timeout 10:

import argparse
import sys

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="devops-toolkit",
        description="Check service health via a REST API.",
    )
    p.add_argument(
        "--url", required=True,
        help="services API endpoint",
    )
    p.add_argument(
        "--timeout", type=float, default=10.0,
        help="request timeout in seconds (default 10)",
    )
    return p

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.timeout <= 0:
        print("timeout must be positive", file=sys.stderr)
        return 1
    # ... fetch, evaluate, report ...
    return 0

if __name__ == "__main__":
    sys.exit(main())

--url is required so the tool fails clearly when it is missing; --timeout has a type and a sane default; -h/--help is generated for free; and main returns an int that sys.exit turns into the process exit code. Taking argv as a parameter also makes main testable — a test can call main(["--url", "http://x", "--timeout", "0"]) and assert the return code without spawning a process.

JSON and YAML Processing

Part 8 filtered JSON with jq in the shell. In Python the same work is a comprehension, with the advantage that you can build structured summaries in the same pass:

def summarize(services: list[dict]) -> dict:
    failed = [
        s for s in services
        if s.get("status") == "failed"
    ]
    return {
        "total": len(services),
        "failed": len(failed),
        "failed_names": [s["name"] for s in failed],
    }

That returns counts and the failed names as a dict you can log, serialize with json.dumps, or assert on in a test — cleaner than stitching together jq and shell variables once the logic grows.

For YAML, use yaml.safe_load and validate the structure you got back:

import yaml

def load_config(path: str) -> dict:
    with open(path) as fh:
        data = yaml.safe_load(fh)   # NOT yaml.load
    if not isinstance(data, dict):
        raise ValueError("config root must be a mapping")
    return data

⚠️ Warning — Always use yaml.safe_load, never yaml.load without a safe loader. Plain yaml.load can construct arbitrary Python objects from the document — an insecure-deserialization hole if the YAML is ever attacker-influenced. Copilot sometimes generates the unsafe form; change it to safe_load and validate that the parsed structure is the shape you expect before using it.

Python for Linux, Docker, and Kubernetes Automation

Python drives the same infrastructure CLIs Bash does, but the safety rules are stricter because it is easy to build a command from a string. Use subprocess.run with an argument list, an explicit timeout, captured output, and a checked return code:

import subprocess

def container_status() -> str:
    result = subprocess.run(
        ["docker", "ps", "--format", "{{.Names}} {{.Status}}"],
        capture_output=True,
        text=True,
        timeout=15,
        check=True,   # non-zero exit -> CalledProcessError
    )
    return result.stdout

The argument list means the shell never parses the command, so a value like ; rm -rf / in a variable is passed as a literal argument, not executed.

⚠️ Warning — Never use shell=True with subprocess, especially with any external or user-supplied input. subprocess.run(f"docker logs {name}", shell=True) is a command-injection hole: a crafted name runs arbitrary shell. Always pass an argument list, always set a timeout, and keep generated automation read-only by default. Copilot reaches for shell=True when a command “looks like a string” — reject it and rewrite as a list.

Docker diagnostics stay read-only: list containers, filter for unhealthy ones, read exit codes — via subprocess as above, or the Docker SDK for Python if you prefer objects over parsing text. No destructive defaults (prune, rm) belong in generated automation without a human gate.

Kubernetes work uses careful kubectl via subprocess, or the official kubernetes Python client, to list Pods, sum restart counts, and print an unhealthy summary:

from kubernetes import client, config

def unhealthy_pods(namespace: str) -> list[str]:
    config.load_kube_config()
    v1 = client.CoreV1Api()
    pods = v1.list_namespaced_pod(namespace)
    return [
        p.metadata.name
        for p in pods.items
        if p.status.phase != "Running"
    ]

This only reads cluster state. Part 7, GitHub Copilot with Kubernetes, covers the manifests and the diagnostic ladder behind these calls; the Kubernetes and Helm guides and the Docker guides go broader.

Python for GitHub Automation

Python talks to the GitHub REST API with an authenticated request — a token from the environment, least scope — to list open issues and pull requests, inspect workflow runs, read repository metadata, or create an issue:

import os
import httpx

def list_open_issues(repo: str) -> list[dict]:
    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        raise RuntimeError("GITHUB_TOKEN is not set")
    resp = httpx.get(
        f"https://api.github.com/repos/{repo}/issues",
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/vnd.github+json",
        },
        params={"state": "open"},
        timeout=10.0,
    )
    resp.raise_for_status()
    return resp.json()

Creating an issue is a POST to the same base with a JSON body — a write, so the token scope and the review bar are higher. The shape of GitHub automation:

Python
   |
GitHub API (authenticated)
   |
Issues / PRs / Actions
   |
 Automation (report / create / label)

This is also the groundwork for AI agents that act on a repository — reading an issue, opening a branch, proposing a change. Part 11, “Building AI Agents with GitHub” (coming soon), builds on exactly these calls, always with human review of whatever the agent proposes.

Python for Infrastructure APIs

The same client pattern — httpx or a maintained SDK, env-var auth, explicit timeouts, status handling, response validation — generalizes across the infrastructure surface: cloud provider APIs, OpenStack, the Kubernetes API, monitoring and alerting systems, Terraform-orchestration and state backends, and the GitHub API above. Keep the specifics general and the discipline constant: whatever the endpoint, treat the response as untrusted data to validate and never hardcode the credential. Copilot drafts these clients quickly; your review is what makes each one safe to point at real infrastructure.

GitHub Copilot for AI API Integration

This is the transition from AI-assisted infrastructure to AI application engineering. The key architectural move is to keep provider code behind a small llm.py abstraction so the rest of your application never imports a vendor SDK directly — which makes providers swappable and the boundary easy to mock in tests.

The current OpenAI Python SDK (v1+) uses a client object:

# llm.py
from openai import OpenAI

client = OpenAI()   # reads OPENAI_API_KEY from env

def complete(prompt: str) -> str:
    resp = client.chat.completions.create(
        model="<model>",   # example; availability changes
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.choices[0].message.content or ""

❗ Important — Use client.chat.completions.create(...) as shown. The old openai.ChatCompletion.create(...) module-level call is deprecated in the v1 SDK — do not use it. AI SDKs change fast: treat model names as examples ("<model>"), do not hardcode a specific model or its pricing, and verify the current client shape against the provider’s official documentation.

The same abstraction can sit in front of other providers. Anthropic uses a messages call:

from anthropic import Anthropic

client = Anthropic()   # reads ANTHROPIC_API_KEY from env

def complete(prompt: str) -> str:
    resp = client.messages.create(
        model="<model>",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp.content[0].text

GitHub Models is another option — accessed via the azure-ai-inference ChatCompletionsClient (or an OpenAI-compatible base URL) using a GitHub token — and it gets its own lesson, Part 13 (coming soon). In every case the API key comes from an environment variable, and the flow is the same:

Python
   |
Provider SDK (behind llm.py)
   |
  Model
   |
 Response
   |
 Validation

That validation step is not optional. Model output is text your program received from a probabilistic system — treat it as a hypothesis to validate, never as a command to run.

Building a Simple AI-Assisted DevOps Tool: AI Log Analyzer

A useful first AI application: feed it sanitized log text and get back a probable-issue summary, likely causes, and suggested diagnostic commands — a triage assistant, not an operator. The pipeline:

Log text
   |
Sanitize (strip secrets)
   |
 Python
   |
AI model (via llm.py)
   |
Suggested diagnosis
   |
Engineer review   <-- required

The structure, with the provider call kept behind llm.py:

import re
from llm import complete

SECRET_RE = re.compile(
    r"(?i)(token|password|secret|authorization)"
    r"\s*[=:]\s*\S+"
)

def sanitize(logs: str) -> str:
    return SECRET_RE.sub(r"\1=[REDACTED]", logs)

def analyze(logs: str) -> str:
    prompt = (
        "You are a DevOps assistant. Given these logs, "
        "summarize the probable issue, list likely causes, "
        "and suggest read-only diagnostic commands. "
        "Do not suggest destructive actions.\n\n"
        f"{sanitize(logs)}"
    )
    return complete(prompt)

The guardrails are the point, not the prompt:

  • Do not auto-execute anything the model suggests. The output is a diagnosis to read, and the diagnostic commands are for a human to run deliberately.
  • Strip secrets before sending. sanitize redacts obvious credentials; keep raw logs local and send the minimum needed.
  • Treat the output as a hypothesis. A confident-sounding “the database is down” is a lead to verify against real signals, not a conclusion.

This is the bridge toward agents: the same shape, plus the ability to act, is what Part 11 builds — and exactly why the human-review gate becomes non-negotiable there.

Refactoring Python with Copilot

Copilot is strong at improving existing Python when you ask for small, specific changes. Useful prompts:

  • “Separate the HTTP call from the business logic in this function.” Splits api.py fetching from health.py deciding, so the logic becomes testable without a network.
  • “Convert these dicts into a dataclass without changing behavior.” Gives the recurring record a name, attribute access, and a repr.
  • “Add type hints to this module without changing behavior.” Annotates signatures so mypy can check them.
  • “Break this 200-line function into small, testable functions.” Turns one untestable blob into named pieces you can cover individually.
  • “Identify unnecessary global state in this module and suggest how to remove it.” Surfaces module-level mutable globals that make behavior order-dependent and tests flaky.

The discipline is to apply these one at a time and review each diff. A small, single-purpose refactor is easy to read and verify; a sweeping “rewrite this module” from an AI produces a large diff where a behavior change hides easily. Small diffs are easier to review than large AI refactors — take the refactor in steps and run the tests after each.

Unit Testing with pytest

Test the pure logic first, because it is deterministic and needs no network. For the service-health example, cover the happy path and the failure modes Copilot will draft for you if you ask:

from devops_toolkit.health import get_failed_services

def test_happy_path_returns_only_failed():
    services = [
        {"name": "api", "status": "running"},
        {"name": "worker", "status": "failed"},
    ]
    result = get_failed_services(services)
    assert [s["name"] for s in result] == ["worker"]

def test_empty_list_returns_empty():
    assert get_failed_services([]) == []

def test_missing_status_key_is_not_failed():
    services = [{"name": "api"}]   # no 'status'
    assert get_failed_services(services) == []

Run them with pytest. Then ask Copilot for the harder cases: a malformed payload, a timeout, and an auth failure at the fetch boundary. Keep fixtures simple — a couple of literal lists cover most of the logic; do not build an elaborate fixture hierarchy for three assertions.

Mocking API Calls

The fetch, HTTP, and AI calls must be mocked in unit tests so they are deterministic, fast, and free — no live endpoint, no API cost, no flakiness. Use unittest.mock, pytest’s monkeypatch, or a library like respx (for httpx) or responses (for requests):

from unittest.mock import patch
from devops_toolkit import api

def test_get_services_parses_payload():
    fake = {"services": [{"name": "api", "status": "failed"}]}
    with patch.object(api.httpx, "get") as mock_get:
        mock_get.return_value.json.return_value = fake
        mock_get.return_value.raise_for_status.return_value = None
        result = api.get_services("http://x", "token")
    assert result[0]["status"] == "failed"

This exercises your parsing and error handling against a controlled response, with no network.

❗ Important — Keep software-logic tests separate from AI-behavior tests. Everything above is deterministic: given fixed input, the code must produce exactly one output, every time. The behavior of a model — is the diagnosis relevant? does it refuse unsafe requests? does it return valid structured output? — is probabilistic and cannot be asserted with ==. That is a different discipline, evaluated with schema checks, keyword rules, and judge models. Part 10, GitHub Actions for AI Applications, builds exactly that — and this separation is why it needs its own lesson.

Static Analysis, Formatting, and Dependencies

Deterministic tooling is what turns a draft into reviewable code. Use Ruff for linting and formatting and mypy for type checking — and do not stack redundant tools:

ruff format .    # format (replaces black)
ruff check .     # lint (replaces flake8 + isort)
mypy .           # static type check
pytest           # tests

Ruff replaces flake8, isort, and black in one fast tool — running all four alongside it is redundant and produces conflicting opinions. mypy checks the type hints you added and catches a whole class of bug (a None where a list was expected) before runtime. The order is a pipeline:

Copilot writes
     |
  format (ruff format)
     |
   lint (ruff check)
     |
type check (mypy)
     |
unit tests (pytest)
     |
  review

Dependencies need the same scrutiny as code. Declare them in pyproject.toml, pin versions (and use a lockfile for applications) so builds are reproducible, and scan for known vulnerabilities in CI. Add only what you need — every dependency is attack surface and maintenance. Copilot sometimes reaches for a third-party package when the standard library already covers the job; ask it “can this be done with the standard library instead?” and often the answer removes a dependency entirely.

⚠️ Warning — Copilot can invent package names that do not exist, or suggest an abandoned or typosquatted one. Before adding any dependency it proposes, confirm the package is real, actively maintained, and the name is spelled exactly right on PyPI. A hallucinated import that a teammate “fixes” by installing a lookalike package is a genuine supply-chain risk.

Never Blindly Trust AI-Generated Python

This is the section to internalize. Copilot writes confident, idiomatic Python — and confident, idiomatic Python with a security hole still has the hole. Review every generated draft for these specific classes of problem:

  • Command injection — building a shell command from input.
  • shell=True in subprocess calls, especially with external data.
  • Unsafe YAML loadingyaml.load without a safe loader.
  • Hardcoded secrets — keys, tokens, or passwords in source.
  • Insecure temp files — predictable paths instead of tempfile.
  • Arbitrary file writes — paths derived from unvalidated input.
  • Missing TLS verificationverify=False on HTTPS calls.
  • Unbounded network requests — no timeout.
  • Missing timeouts on subprocess or HTTP calls.
  • Overly broad exception handlingexcept Exception: pass.
  • Insecure deserializationpickle or yaml.load on untrusted data.
  • SQL injection — string-formatted queries instead of parameters.
  • Exposed debug endpointsdebug=True, a bound 0.0.0.0 debugger, or a stack-trace page in production.

A practical habit: after Copilot generates a non-trivial module, ask it explicitly, “Review this Python as a security engineer and flag command injection, shell=True, unsafe deserialization, hardcoded secrets, missing timeouts, and missing TLS verification.” It is genuinely useful at spotting its own issues when prompted to look — but the human reading the diff, not the model, is the gate. AI review is a first pass, not the approval.

GitHub Copilot + Python + GitHub Actions

Python that matters should be formatted, linted, type-checked, and tested in CI on every push and pull request. Ask Copilot to “write a GitHub Actions workflow that runs Ruff, mypy, and pytest with least-privilege permissions.” The verified building blocks:

name: python-ci

on: [push, pull_request]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install dependencies
        run: pip install -e ".[dev]"

      - name: Lint and format check
        run: |
          ruff check .
          ruff format --check .

      - name: Type check
        run: mypy .

      - name: Unit tests
        run: pytest

What to verify in any Copilot-generated Python workflow:

  • actions/checkout@v4 and actions/setup-python@v5 are the current major versions — confirm them and reject invented or unversioned uses:.
  • Least-privilege permissions:. A lint-and-test job needs only contents: read. Copilot often omits the block; add it and grant nothing more.
  • cache: "pip" speeds installs without caching anything sensitive — appropriate here, never for secrets.
  • Each step actually fails on findings. ruff check, mypy, and pytest all exit non-zero on problems, which fails the job — a green pipeline that never blocks is theater.

A security-checks step (dependency and static scanning) fits naturally alongside these. This CI foundation is what Part 10, GitHub Actions for AI Applications, extends into evaluating AI behavior, packaging, and gated delivery.

30 GitHub Copilot Prompts for Python and DevOps Engineers

Reusable starting prompts. Each produces a draft to read, type-check, lint, and test before you run it.

Structure and typing

  1. “Scaffold a src-layout Python package with a pyproject.toml for Ruff, mypy, and pytest.”
  2. “Refactor this script into cli.py, api.py, health.py, and config.py modules.”
  3. “Add useful type hints to this module without overcomplicating it.”
  4. “Convert these related dicts into a dataclass without changing behavior.”
  5. “Break this 200-line function into small, testable functions.”
  6. “Identify unnecessary global state in this module and remove it.”

REST APIs and data

  1. “Write an httpx client with an explicit timeout on every request.”
  2. “Add status-code handling and raise_for_status to this API call.”
  3. “Add a bounded retry with backoff around this idempotent GET only.”
  4. “Parse this JSON payload into a list of ServiceStatus dataclasses.”
  5. “Summarize failed services with counts from this JSON.”
  6. “Load this YAML with yaml.safe_load and validate the structure.”

Secrets, errors, logging

  1. “Read an API token from an environment variable and validate it.”
  2. “Replace this broad except Exception with specific exceptions and exit codes.”
  3. “Add stdlib logging with levels; make sure no secrets are logged.”
  4. “Add argparse with required args, defaults, help, and validation.”

Infrastructure automation

  1. “Wrap the docker CLI with subprocess using an argument list and a timeout.”
  2. “Rewrite this subprocess call to remove shell=True safely.”
  3. “List non-Running Pods in a namespace with the kubernetes Python client.”
  4. “Summarize Pod restart counts across a namespace, read-only.”
  5. “List open GitHub issues for a repo using a token from the environment.”
  6. “Create a GitHub issue via the REST API with proper error handling.”

AI integration

  1. “Create an llm.py abstraction wrapping an AI provider SDK.”
  2. “Show the current OpenAI Python SDK chat completion call, not the deprecated one.”
  3. “Sanitize secrets out of log text before sending it to a model.”
  4. “Build an AI log analyzer that returns a diagnosis for engineer review, never auto-run.”

Testing and quality

  1. “Write pytest tests for happy path, empty input, and malformed data.”
  2. “Mock this httpx call with respx so the test needs no network.”
  3. “Review this Python as a security engineer for injection, shell=True, and secrets.”
  4. “Write a GitHub Actions workflow running Ruff, mypy, and pytest with least-privilege permissions.”

Lab: Build an AI-Assisted Infrastructure Health CLI with Python and GitHub Copilot

Put the whole lesson together by building a real infrastructure health CLI with Copilot as your assistant — the point is the cycle, not the exact output. Work each step through the loop: Copilot proposes → you read → Ruff/mypy/pytest validate → you approve.

  1. Describe the requirement — a CLI that fetches services from a REST API, reports the failed ones, and exits non-zero if any failed.
  2. Scaffold — have Copilot create the src-layout package and a pyproject.toml with Ruff, mypy, and pytest. Trim redundant tools.
  3. Draft — let Copilot produce a first single-file version. Do not run it against anything real yet.
  4. Inspect — read every line; note the missing timeout, unchecked status, broad except, print, and hardcoded URL.
  5. Split modules — separate api.py (HTTP), health.py (logic), config.py (env), and cli.py (argparse).
  6. Add timeouts — put an explicit timeout= on every network call.
  7. Handle status and errors — add raise_for_status and specific except blocks with distinct exit codes.
  8. Secrets from env — read API_TOKEN/API_URL from the environment with validation; add .env.example, gitignore .env.
  9. Type hints — annotate signatures; make mypy . pass.
  10. Dataclass — model ServiceStatus and construct it at the parse boundary.
  11. Logging — replace print with stdlib logging; confirm no secret is logged.
  12. argparse — add --url, --timeout, --verbose, help, and validation; run as python -m devops_toolkit.cli.
  13. Unit tests — cover happy path, empty, malformed, timeout, and auth-failure; mock HTTP so tests need no network.
  14. Add the AI extra (optional) — behind llm.py, add a log-analyzer command that returns a diagnosis for review, never auto-run.
  15. Lint, format, type checkruff format ., ruff check ., mypy .; fix each finding rather than silencing it.
  16. Security review — run the “review as a security engineer” prompt; check for shell=True, unsafe YAML, hardcoded secrets, missing timeouts.
  17. Dependencies — pin them in pyproject.toml, confirm every package is real and maintained, and remove any the stdlib covers.
  18. Actions CI — add the python-ci.yml workflow so every push and PR runs Ruff, mypy, and pytest with permissions: { contents: read }, then open a PR for review.

The end-to-end shape you have practiced:

Requirement
     |
   Copilot
     |
   Python
     |
Lint / type check (ruff / mypy)
     |
   pytest
     |
GitHub Actions
     |
     PR
     |
Human Review   <-- required

Commit each piece only after you have read it, run Ruff and mypy, and passed the tests. By the end you will have used Copilot to write, refactor, harden, test, and ship a real Python automation tool — while keeping the tooling and your review as the source of truth.

🛠️ DevOps Tip — Add a repo-level custom instructions file so Copilot defaults to your Python conventions — explicit timeouts, env-var secrets, specific exceptions, stdlib logging, subprocess arg lists with no shell=True, Ruff-clean and mypy-typed — across the whole project. Verify the current custom-instructions mechanism in the VS Code docs, since it evolves. The Bash and Python automation guides and the Docker Academy go deeper on the systems these tools automate.

What’s Next

You now have Copilot working across Python for DevOps and AI engineering: the Bash-to-Python handoff, a modern src-layout package, a first automation script hardened with timeouts and specific exception handling, pure testable functions, type hints and dataclasses, safe REST clients, env-var secrets, stdlib logging, argparse CLIs, JSON and yaml.safe_load processing, subprocess with arg lists (never shell=True), Docker/Kubernetes/GitHub/infra automation, AI integration behind an llm.py abstraction, the AI Log Analyzer, Copilot-assisted refactoring, pytest with mocked calls, Ruff and mypy and dependency hygiene, a security review checklist, and a CI workflow — all under the discipline that Python which runs once is not automatically production-ready.

The next lesson, Part 10, GitHub Actions for AI Applications (published), automates everything you just built. The bridge is worth holding onto: Part 9 builds the AI-capable Python app; Part 10 automates its testing, evaluation, packaging, and delivery — and it draws the line this lesson introduced between deterministic software tests and probabilistic AI evaluations, then puts a human-approval gate in front of production. Part 11, “Building AI Agents with GitHub” (coming soon), takes the GitHub API and AI integration here and lets code act on a repository, with review.

To revisit where this handoff began, return to Part 8, GitHub Copilot for Bash; for the editor surfaces you generate this Python in, see Part 4, GitHub Copilot in VS Code, and Part 3, GitHub Copilot CLI. The GitHub AI Engineering Academy home has the full path, and the Bash and Python automation guides, the Docker guides, the Kubernetes and Helm guides, and the Docker Academy all go deeper on the systems this Python automates.

Recommended GitHub Books

Affiliate Disclosure: Some links on this page are affiliate links. If you purchase through one of these links, DevOps AI Toolkit may earn a commission at no additional cost to you. See our affiliate disclosure.

Frequently asked questions

Is GitHub Copilot good for Python?

Yes — Python is arguably where Copilot is strongest. The language is ubiquitous in its training data, the standard library is well documented, and the idioms are stable, so Copilot drafts functions, type hints, tests, and API clients quickly and usually idiomatically. That does not make the output finished. Copilot happily invents package names that do not exist, imports the wrong module, calls a deprecated API shape, swallows exceptions, and hardcodes values a real tool should take as arguments. Treat what it writes as a strong first draft to read, type-check with mypy, lint and format with Ruff, and cover with pytest before it runs anything that matters. Copilot accelerates the typing; your review, your tooling, and your tests are the source of truth.

Can GitHub Copilot write Python automation?

Yes, and this is one of its best uses for DevOps and platform engineers. Health checks, API pollers, log processors, cloud and Kubernetes inventory scripts, GitHub automation, report generators — Copilot drafts the boilerplate fast. The risks are specific and predictable: missing timeouts on network calls, `shell=True` in subprocess calls, unbounded retries, broad `except Exception` blocks that hide failures, and secrets pasted inline. Review generated automation for exactly those patterns, add explicit timeouts and specific exception handling, validate any dependency it reaches for, and test against safe targets before pointing it at production infrastructure.

Can Copilot create Python API clients?

Yes. Give it an endpoint, an auth scheme, and the response shape and it will draft a client using httpx or requests, parse the JSON, and branch on status codes. Check three things every time: that every request has an explicit `timeout=`, that the auth token comes from an environment variable rather than a literal in the source, and that status codes and error responses are actually handled rather than assumed. Copilot also tends to assume a response schema that may not match reality, so validate the parsed structure and test against recorded sample responses rather than trusting the happy path it imagined.

Can Copilot write pytest tests?

Yes — drafting pytest tests is a genuine time-saver, and asking Copilot for the failure cases you would not have bothered to write is where it earns its keep: malformed JSON, a timeout, an auth failure, an empty list. Two cautions. First, mock external HTTP and AI calls (with `unittest.mock`, monkeypatch, or respx) so tests are deterministic and cost nothing to run. Second, Copilot sometimes writes tests that assert whatever the current code does, bugs included, so read each test and confirm it encodes the behavior you actually want. And keep deterministic software tests separate from probabilistic AI-output evaluation — that distinction matters and is the subject of Part 10.

Is Copilot-generated Python production-ready?

Not as-is. A snippet that runs on your laptop is not automatically production-ready — it may lack timeouts, leak secrets in logs, catch exceptions too broadly, depend on a package that should not be trusted, or carry a security flaw like `shell=True` or unsafe YAML loading. Production readiness comes from the pipeline around the code: read it, format and lint with Ruff, type-check with mypy, cover it with pytest, review it for security, pin and scan its dependencies, and run it in a test environment before it touches real systems. Copilot writes the first draft; the engineering process is what makes it production-ready.

Can Python replace Bash for DevOps?

For some jobs, yes; for others, no — they are complementary. Bash remains the right tool for shell orchestration, chaining CLIs, simple system tasks, and CI glue. Python takes over as complexity and data handling grow: nested JSON, REST APIs with pagination and retries, reusable libraries, concurrency, real error handling, structured logging, database access, AI SDK integration, and code a team must maintain for years. A good rule from Part 8 carries over: if you are simulating data structures in Bash or writing more than a page of branching logic, it is time for Python. Choose per task rather than trying to make one language do everything.

Can GitHub Copilot help with Docker or Kubernetes Python automation?

Yes. Copilot can draft subprocess wrappers around the `docker` and `kubectl` CLIs, or code against the Docker SDK and the official `kubernetes` Python client — listing containers, detecting unhealthy ones, summarizing Pod restart counts, reporting non-running Pods. Keep two rules front of mind. Use subprocess with argument lists and an explicit timeout, never `shell=True`, to avoid command injection. And keep generated automation read-only by default: inspecting and reporting is safe, deleting and applying is not, so gate anything that mutates infrastructure behind human review. Part 7 covers the Kubernetes side in depth.

Can Python interact with the GitHub API?

Yes, and it is a common automation target. With an authenticated request — a token from an environment variable, least scope — you can list issues and pull requests, inspect workflow runs, read repository metadata, and create an issue. You can call the REST API directly with httpx or requests, or use a maintained client library. The same discipline applies as any API client: explicit timeouts, status-code handling, no hardcoded token, and validation of the response shape. This is also the groundwork for AI agents that act on a repository, the subject of a later coming-soon lesson — with human review of anything an agent proposes.

Can Copilot help build AI applications in Python?

Yes — Copilot can scaffold calls to AI provider SDKs and wire up a small application around them. Verify the SDK shape it produces against current documentation, because these change: the current OpenAI Python SDK uses `client.chat.completions.create(...)`, not the deprecated `openai.ChatCompletion.create`, and Anthropic uses `client.messages.create(...)`. Keep provider code behind a small abstraction (an `llm.py`) so you can swap providers, read API keys from environment variables, and treat model output as a hypothesis to validate rather than a command to execute. And remember AI output is probabilistic: you test the software logic deterministically and evaluate the model behavior separately.

How should secrets be handled in Copilot-generated Python code?

Never hardcoded. Read secrets from environment variables with `os.environ.get("API_TOKEN")` and validate that they are present before use, keep a local `.env` file out of Git via `.gitignore` while committing a `.env.example` with placeholder names, and source real values from GitHub Actions secrets or a secret manager in CI and production. Copilot will sometimes paste a literal key or token into a snippet to make an example run — always replace it with an environment lookup. Just as important: never log tokens, headers, or sensitive payloads. Review generated logging and exception handling to be sure a secret cannot leak into stdout or a log aggregator.

← Back to GitHub AI Engineering Academy

Related on DevOps AI Toolkit