diff --git a/data-track/week-5/README.md b/data-track/week-5/README.md new file mode 100644 index 0000000..3657fc3 --- /dev/null +++ b/data-track/week-5/README.md @@ -0,0 +1,94 @@ +# HYF Data Track — Week 5 Practice Exercises + +Seven exercises that consolidate Week 5 (containers & CI/CD): writing Dockerfiles, managing dependencies for reproducible builds, and automating checks with GitHub Actions. + +Work through them in order. Exercises 1–4 build on each other (pipeline → caching → uv → comparison). Exercises 5–7 are standalone. + +## Layout + +| Folder | Topic | Concepts | +|---|---|---| +| [`exercise_1/`](exercise_1/) | Minimal Pipeline to Container | Dockerfile basics, `ENV`, `CMD` | +| [`exercise_2/`](exercise_2/) | Cache-Friendly Dockerfile | Layer ordering, `requirements.txt` | +| [`exercise_3/`](exercise_3/) | Cache-Friendly Dockerfile with uv | `uv sync --frozen`, `pyproject.toml`, `uv.lock` | +| [`exercise_4/`](exercise_4/) | Compare Both Docker Approaches | `requirements.txt` vs `uv`, trade-offs | +| [`exercise_5/`](exercise_5/) | CI Smoke Test | GitHub Actions, `pytest`, breaking CI intentionally | +| [`exercise_6/`](exercise_6/) | Environment Variable Patterns | `-e`, `--env-file`, `ARG` vs `ENV` | +| [`exercise_7/`](exercise_7/) | Image Tagging Strategy | `docker tag`, commit SHA, multi-environment tags | + +```text +week-5/ +├── exercise_1/ +│ ├── src/pipeline.py # starter pipeline script +│ ├── Dockerfile # student fills the TODOs +│ ├── README.md +│ └── solutions/ +│ └── Dockerfile # reference answer with # WHY comments +├── exercise_2/ +│ ├── src/pipeline.py +│ ├── requirements.txt +│ ├── Dockerfile # BAD ordering — student fixes it +│ ├── README.md +│ └── solutions/ +│ └── Dockerfile +├── exercise_3/ +│ ├── src/pipeline.py +│ ├── pyproject.toml +│ ├── uv.lock +│ ├── Dockerfile # student fills the TODOs +│ ├── README.md +│ └── solutions/ +│ └── Dockerfile +├── exercise_4/ +│ ├── README.md # written comparison task +│ └── solutions/ +│ └── answers.md +├── exercise_5/ +│ ├── tests/ +│ │ └── test_smoke.py # student creates this +│ ├── .github/ +│ │ └── workflows/ +│ │ └── ci.yml # student creates this +│ ├── README.md +│ └── solutions/ +│ ├── test_smoke.py +│ └── ci.yml +├── exercise_6/ +│ ├── src/pipeline.py +│ ├── .env.example +│ ├── Dockerfile +│ ├── README.md +│ └── solutions/ +│ └── Dockerfile +└── exercise_7/ + ├── README.md + └── solutions/ + └── answers.md +``` + +## Open in GitHub Codespaces + +> 💻 [Open in GitHub Codespaces](https://github.com/codespaces/new/HackYourFuture/Learning-Resources?devcontainer_path=.devcontainer%2Fdata-track%2Fdevcontainer.json) + +One Codespace covers all seven exercises. From the Explorer, navigate into `data-track/week-5/exercise_N/`. + +**Note:** Exercises 1–3, 6–7 require Docker. The Codespace devcontainer includes Docker-in-Docker. If you work locally, make sure Docker Desktop is running. + +## Clone locally + +```bash +git clone https://github.com/HackYourFuture/Learning-Resources.git +cd Learning-Resources/data-track/week-5 +``` + +## Reference solutions (peek only after attempting) + +Each `exercise_N/solutions/` folder holds the reference answer. The original `# TODO` comments are preserved, and `# WHY ...:` notes explain the non-obvious choices. + +**Read the WHY notes, not just the code.** The reasoning is what carries into real projects. + +Time-box yourself: 15–30 minutes of honest attempt before opening `solutions/`. You can diff your work against the reference: + +```bash +diff exercise_1/Dockerfile exercise_1/solutions/Dockerfile +``` diff --git a/data-track/week-5/exercise_1/Dockerfile b/data-track/week-5/exercise_1/Dockerfile new file mode 100644 index 0000000..2fe3e1b --- /dev/null +++ b/data-track/week-5/exercise_1/Dockerfile @@ -0,0 +1,15 @@ +# TODO 1: Choose a base image. Use python:3.11-slim. +FROM ??? + +# TODO 2: Set the working directory inside the container to /app. +WORKDIR ??? + +# TODO 3: Copy requirements.txt into the container. +# (This exercise has no requirements.txt — skip this step.) + +# TODO 4: Copy the src/ folder into the container. +COPY ??? ??? + +# TODO 5: Set the default command to run the pipeline module. +# Use: python src/pipeline.py +CMD ??? diff --git a/data-track/week-5/exercise_1/README.md b/data-track/week-5/exercise_1/README.md new file mode 100644 index 0000000..d078d60 --- /dev/null +++ b/data-track/week-5/exercise_1/README.md @@ -0,0 +1,38 @@ +# Exercise 1: Minimal Pipeline to Container + +Package a small Python script into a Docker image and run it with an environment variable. + +## Setup + +No extra dependencies. `src/pipeline.py` uses only the standard library. + +## Task + +1. Open `Dockerfile` and fill in the five TODOs. +2. Build the image: + ```bash + docker build -t pipeline-practice:1.0 . + ``` +3. Run the container **without** `API_KEY` and confirm the output: + ``` + API key present: False + ``` +4. Run it **with** `API_KEY` set: + ```bash + docker run --rm -e API_KEY=demo pipeline-practice:1.0 + ``` + Expected output: + ``` + API key present: True + ``` + +## Success criteria + +- `docker build` completes without errors. +- Running without `-e API_KEY` prints `API key present: False`. +- Running with `-e API_KEY=demo` prints `API key present: True`. + +## Stretch + +- Change the `CMD` to use the exec form (`["python", "src/pipeline.py"]`) if you used the shell form. What is the difference? +- Add a `LABEL maintainer="yourname"` instruction. Run `docker inspect pipeline-practice:1.0` and find it. diff --git a/data-track/week-5/exercise_1/solutions/Dockerfile b/data-track/week-5/exercise_1/solutions/Dockerfile new file mode 100644 index 0000000..4a443cd --- /dev/null +++ b/data-track/week-5/exercise_1/solutions/Dockerfile @@ -0,0 +1,21 @@ +# TODO 1: Choose a base image. Use python:3.11-slim. +# WHY python:3.11-slim: the full python:3.11 image is ~900MB. The slim variant strips +# documentation, tests, and unused locale data, bringing it to ~130MB. For a pipeline +# that only needs the standard library, slim is the right default. +FROM python:3.11-slim + +# TODO 2: Set the working directory inside the container to /app. +# WHY /app: a dedicated working directory keeps container paths predictable and avoids +# accidentally writing files into system directories like /usr or /. +WORKDIR /app + +# TODO 4: Copy the src/ folder into the container. +# WHY COPY src/ src/: copies only the source directory, not the whole project. Smaller +# context means faster builds and no risk of leaking .env or other local files. +COPY src/ src/ + +# TODO 5: Set the default command to run the pipeline module. +# WHY CMD vs RUN: RUN executes at build time; CMD sets the default at run time. +# Using a JSON array ("exec form") avoids spawning a shell, so signals like SIGTERM +# reach the Python process directly instead of being swallowed by a shell wrapper. +CMD ["python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_1/src/pipeline.py b/data-track/week-5/exercise_1/src/pipeline.py new file mode 100644 index 0000000..0a115a1 --- /dev/null +++ b/data-track/week-5/exercise_1/src/pipeline.py @@ -0,0 +1,4 @@ +import os + +api_key = os.environ.get("API_KEY", "missing") +print(f"API key present: {api_key != 'missing'}") diff --git a/data-track/week-5/exercise_2/Dockerfile b/data-track/week-5/exercise_2/Dockerfile new file mode 100644 index 0000000..9218286 --- /dev/null +++ b/data-track/week-5/exercise_2/Dockerfile @@ -0,0 +1,16 @@ +# BAD: This Dockerfile copies all source code before installing dependencies. +# Every code change — even a single blank line in pipeline.py — invalidates +# the pip install cache layer, forcing a full reinstall on each build. + +FROM python:3.11-slim + +WORKDIR /app + +# TODO 1: Identify which COPY + RUN pair below causes slow rebuilds. +# Then reorder the instructions so dependency installs are cached +# separately from source code changes. + +COPY . . +RUN pip install -r requirements.txt + +CMD ["python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_2/README.md b/data-track/week-5/exercise_2/README.md new file mode 100644 index 0000000..569cb50 --- /dev/null +++ b/data-track/week-5/exercise_2/README.md @@ -0,0 +1,27 @@ +# Exercise 2: Cache-Friendly Dockerfile + +Reorder a Dockerfile so that `pip install` is cached separately from source code changes. + +## Setup + +No extra setup — `src/pipeline.py` uses only `os` from the standard library. `requests` is in `requirements.txt` but not imported yet; it represents a real project dependency. + +## Task + +1. Build the image as-is and note how long the install step takes: + ```bash + docker build -t pipeline-practice:2.0 . + ``` +2. Add a blank line to `src/pipeline.py` and build again. Observe that `pip install` runs again from scratch. +3. Open `Dockerfile` and fix the `TODO 1`: reorder the `COPY` and `RUN` instructions so dependency installs are cached. +4. Build again after the fix, then add another blank line to `src/pipeline.py` and build a fourth time. Confirm that `pip install` is now served from cache (`---> Using cache`). + +## Success criteria + +- After fixing the Dockerfile, editing `src/pipeline.py` does **not** trigger a pip reinstall. +- `docker build` output shows `---> Using cache` for the pip install layer after a code-only change. + +## Stretch + +- Add a second package to `requirements.txt` (e.g. `pydantic==2.6.1`) and rebuild. Is the install layer invalidated? Why? +- What happens if you remove `requirements.txt` from `.dockerignore` but you already have it in the image? diff --git a/data-track/week-5/exercise_2/requirements.txt b/data-track/week-5/exercise_2/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/data-track/week-5/exercise_2/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/data-track/week-5/exercise_2/solutions/Dockerfile b/data-track/week-5/exercise_2/solutions/Dockerfile new file mode 100644 index 0000000..84c3072 --- /dev/null +++ b/data-track/week-5/exercise_2/solutions/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +# TODO 1: Identify which COPY + RUN pair below causes slow rebuilds. +# Then reorder the instructions so dependency installs are cached +# separately from source code changes. + +# WHY copy requirements.txt first: Docker builds images as a stack of layers. +# Each instruction produces a layer. If a layer's inputs have not changed, +# Docker reuses the cached layer and skips re-executing it. +# +# By copying requirements.txt before any source code, the pip install layer +# only reruns when requirements.txt itself changes — not when pipeline.py changes. +# For a project with many dependencies, this can save 30–120 seconds per build. +COPY requirements.txt . +RUN pip install -r requirements.txt + +# WHY copy source code after pip install: source code changes on every feature commit. +# Placing it after the dependency layer means code edits only invalidate this final +# COPY layer, not the expensive install step above. +COPY src/ src/ + +CMD ["python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_2/src/pipeline.py b/data-track/week-5/exercise_2/src/pipeline.py new file mode 100644 index 0000000..0a115a1 --- /dev/null +++ b/data-track/week-5/exercise_2/src/pipeline.py @@ -0,0 +1,4 @@ +import os + +api_key = os.environ.get("API_KEY", "missing") +print(f"API key present: {api_key != 'missing'}") diff --git a/data-track/week-5/exercise_3/Dockerfile b/data-track/week-5/exercise_3/Dockerfile new file mode 100644 index 0000000..4704db1 --- /dev/null +++ b/data-track/week-5/exercise_3/Dockerfile @@ -0,0 +1,24 @@ +# TODO 1: Start from the official Python 3.11-slim base image. +FROM ??? + +WORKDIR /app + +# TODO 2: Copy the uv binary from the official uv image. +# Use: COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /usr/local/bin/uv +COPY --from=??? ??? ??? + +# TODO 3: Copy pyproject.toml and uv.lock into the container. +# These must be copied BEFORE your source code so the install +# layer is cached separately from code changes. +COPY ??? ??? + +# TODO 4: Install dependencies using uv with the --frozen and --no-dev flags. +# --frozen: respect the lock file exactly, do not re-resolve. +# --no-dev: skip development dependencies (linters, test runners). +RUN uv sync ??? + +# TODO 5: Copy the rest of the source code. +COPY ??? ??? + +# TODO 6: Set the default run command using uv run. +CMD ??? diff --git a/data-track/week-5/exercise_3/README.md b/data-track/week-5/exercise_3/README.md new file mode 100644 index 0000000..73f886d --- /dev/null +++ b/data-track/week-5/exercise_3/README.md @@ -0,0 +1,48 @@ +# Exercise 3: Cache-Friendly Dockerfile with uv + +Build a Docker image that uses `uv` for locked dependency installs. + +## Setup + +You need `uv` installed locally to regenerate `uv.lock`. Install it once: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +The `uv.lock` is already committed in this exercise folder. If you want to see how it is generated from scratch: + +```bash +uv lock +``` + +## Task + +1. Fill in the six TODOs in `Dockerfile`. +2. Build the image: + ```bash + docker build -t pipeline-practice:3.0 . + ``` +3. Run it and confirm the output: + ```bash + docker run --rm -e API_KEY=demo pipeline-practice:3.0 + ``` + Expected: + ``` + API key present: True + ``` +4. Edit only `src/pipeline.py` (add a comment) and build again. Confirm the `uv sync` layer stays cached. +5. Add a second dependency to `pyproject.toml`, run `uv lock` to update `uv.lock`, and build again. Confirm `uv sync` now reruns. +6. **Intentional failure:** bump the version in `pyproject.toml` without running `uv lock`. Try `uv sync --frozen` locally. Read the error — this is exactly what CI should throw when a lock file is stale. + +## Success criteria + +- Image builds and runs with the expected output. +- Editing source code does not invalidate the `uv sync` layer. +- Changing `pyproject.toml` + updating `uv.lock` does invalidate the layer. +- Running `uv sync --frozen` after editing `pyproject.toml` (without `uv lock`) produces an error. + +## Stretch + +- Compare the image size of Exercise 2 (pip) vs Exercise 3 (uv): `docker images pipeline-practice`. +- What would happen if you forgot `--frozen` in the `RUN` step? diff --git a/data-track/week-5/exercise_3/pyproject.toml b/data-track/week-5/exercise_3/pyproject.toml new file mode 100644 index 0000000..416cd00 --- /dev/null +++ b/data-track/week-5/exercise_3/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "weather-pipeline" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "requests==2.31.0", +] diff --git a/data-track/week-5/exercise_3/solutions/Dockerfile b/data-track/week-5/exercise_3/solutions/Dockerfile new file mode 100644 index 0000000..459cce5 --- /dev/null +++ b/data-track/week-5/exercise_3/solutions/Dockerfile @@ -0,0 +1,38 @@ +# TODO 1: Start from the official Python 3.11-slim base image. +# WHY slim: saves ~770MB vs the full image. No documentation or test data needed +# in a production container. +FROM python:3.11-slim + +WORKDIR /app + +# TODO 2: Copy the uv binary from the official uv image. +# WHY COPY --from: this is a Docker multi-stage copy. Instead of installing uv +# via pip (which adds a pip dependency and is slower), we pull only the compiled +# binary from the official uv image. The result: a smaller final image with no +# pip layer involved. +COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /usr/local/bin/uv + +# TODO 3: Copy pyproject.toml and uv.lock into the container. +# WHY before source code: same caching logic as Exercise 2. uv.lock changes +# only when dependencies change; source code changes on every commit. Separating +# them means the install layer stays cached across code edits. +COPY pyproject.toml uv.lock ./ + +# TODO 4: Install dependencies using uv with the --frozen and --no-dev flags. +# WHY --frozen: without this flag, uv re-resolves the dependency graph from +# pyproject.toml and may pick newer versions than what uv.lock specifies. This +# defeats the purpose of committing a lock file — you would no longer be +# guaranteed the same environment across machines and CI runs. +# WHY --no-dev: pytest, ruff, and other dev tools are not needed at runtime. +# Including them inflates the image and widens the attack surface. +RUN uv sync --frozen --no-dev + +# TODO 5: Copy the rest of the source code. +# WHY copy src/ separately: keeps source code changes from busting the uv sync layer. +COPY src/ src/ + +# TODO 6: Set the default run command using uv run. +# WHY uv run: uv run activates the managed virtual environment and then executes +# the command. This is the correct way to use the uv-managed venv inside Docker +# rather than manually sourcing the venv or relying on PATH manipulation. +CMD ["uv", "run", "python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_3/src/pipeline.py b/data-track/week-5/exercise_3/src/pipeline.py new file mode 100644 index 0000000..0a115a1 --- /dev/null +++ b/data-track/week-5/exercise_3/src/pipeline.py @@ -0,0 +1,4 @@ +import os + +api_key = os.environ.get("API_KEY", "missing") +print(f"API key present: {api_key != 'missing'}") diff --git a/data-track/week-5/exercise_3/uv.lock b/data-track/week-5/exercise_3/uv.lock new file mode 100644 index 0000000..e826977 --- /dev/null +++ b/data-track/week-5/exercise_3/uv.lock @@ -0,0 +1,145 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "weather-pipeline" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = "==2.31.0" }] diff --git a/data-track/week-5/exercise_4/README.md b/data-track/week-5/exercise_4/README.md new file mode 100644 index 0000000..3fa8a9a --- /dev/null +++ b/data-track/week-5/exercise_4/README.md @@ -0,0 +1,27 @@ +# Exercise 4: Compare Both Docker Approaches + +A written comparison exercise. No code to write — this is about understanding trade-offs. + +## Task + +Open your Dockerfiles from Exercise 2 (requirements.txt) and Exercise 3 (uv) side by side. + +Answer the following questions in writing (a text file, a comment, or just think them through): + +1. Which files does each Dockerfile copy before the dependency install step? + - Exercise 2 (requirements.txt): copies `___`. + - Exercise 3 (uv): copies `___` and `___`. + +2. Why does the uv Dockerfile need to copy two files before the install step, while the requirements.txt Dockerfile only needs one? + +3. `uv sync --frozen` is stricter than `pip install -r requirements.txt`. Explain what "stricter" means here and why that matters in CI. + +4. Your team already has a `requirements.txt` and a working Dockerfile. A colleague suggests switching to `uv`. List one concrete reason to switch and one concrete reason to stay. + +5. Which approach would you use for a brand-new project you control entirely? Why? + +## Success criteria + +You can explain the caching difference and the `--frozen` guarantee without looking at the chapter. + +Check your answers against `solutions/answers.md`. diff --git a/data-track/week-5/exercise_4/solutions/answers.md b/data-track/week-5/exercise_4/solutions/answers.md new file mode 100644 index 0000000..3b201bb --- /dev/null +++ b/data-track/week-5/exercise_4/solutions/answers.md @@ -0,0 +1,32 @@ +# Exercise 4: Reference Answers + +## Question 1: Files copied before install + +- **Exercise 2 (requirements.txt):** copies `requirements.txt`. +- **Exercise 3 (uv):** copies `pyproject.toml` and `uv.lock`. + +## Question 2: Why uv needs two files + +`uv` stores two pieces of information separately: the *declared* dependencies (in `pyproject.toml`) and the *resolved* full dependency graph including transitive packages (in `uv.lock`). Both files are needed before `uv sync --frozen` so uv can verify that the declared and resolved versions are in sync and then install exactly what the lock file says. + +`pip` only needs `requirements.txt` because pinned requirements already list the specific versions to install. There is no separate resolution step at install time. + +## Question 3: What "stricter" means + +`pip install -r requirements.txt` installs exactly the packages and versions listed. If `requests==2.31.0` is listed, pip installs that version. But transitive dependencies (what `requests` itself depends on, like `urllib3`) are resolved at install time and may differ between runs. + +`uv sync --frozen` refuses to run if `pyproject.toml` and `uv.lock` have drifted apart. This means: +- If someone edits `pyproject.toml` without updating `uv.lock`, the install fails loudly. +- The full transitive graph is pinned in `uv.lock`, so every run installs byte-for-byte the same environment. + +In CI, this is the right behaviour: a stale lock file is a bug, and you want CI to catch it immediately rather than silently installing a slightly different environment. + +## Question 4: Switch vs stay + +**Reason to switch:** `uv.lock` pins the complete dependency tree including transitive packages. This prevents "it worked last week" failures caused by a transitive package releasing a breaking update. + +**Reason to stay:** The team's existing Dockerfiles, CI scripts, and deployment pipelines are already wired around `requirements.txt`. Migrating introduces risk and requires updating every Dockerfile and CI step. If the current setup is stable and the team is not experiencing lock drift problems, the switching cost may not be worth it. + +## Question 5: New project + +Prefer `uv`. Starting fresh means zero migration cost. You get `uv.lock` (full dependency pinning), faster installs, and `uv sync --frozen` as an automatic guard against lock drift in CI and Docker from day one. diff --git a/data-track/week-5/exercise_5/.github/workflows/ci.yml b/data-track/week-5/exercise_5/.github/workflows/ci.yml new file mode 100644 index 0000000..c484458 --- /dev/null +++ b/data-track/week-5/exercise_5/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # TODO 1: Add a step to set up Python 3.11. + # Use: actions/setup-python@v5 with python-version: "3.11" + + # TODO 2: Add a step to install pytest. + # Use: pip install pytest + + # TODO 3: Add a step to run pytest. + # Use: pytest -q diff --git a/data-track/week-5/exercise_5/README.md b/data-track/week-5/exercise_5/README.md new file mode 100644 index 0000000..0aea5fe --- /dev/null +++ b/data-track/week-5/exercise_5/README.md @@ -0,0 +1,27 @@ +# Exercise 5: CI Smoke Test + +Wire a GitHub Actions workflow that runs pytest on every push, then confirm CI catches a broken test. + +## Setup + +You need a GitHub repository for this exercise. Use your Week 5 assignment repo or create a new one. Copy the `tests/` and `.github/` folders from this exercise into it. + +## Task + +1. Copy `tests/test_smoke.py` into your repository's `tests/` folder. +2. Open `.github/workflows/ci.yml` and fill in the three TODOs. +3. Push to GitHub and confirm the Actions tab shows a green run. +4. Change `assert True` to `assert False` in `tests/test_smoke.py` and push. Confirm CI fails. +5. Revert the change and push again. Confirm CI goes green. + +## Success criteria + +- CI runs on every push to `main` and on every pull request. +- A broken test causes the CI job to fail. +- A passing test causes the CI job to succeed. +- You can read the test output in the Actions tab. + +## Stretch + +- Add a `ruff check .` step before the test step. Break it by writing a line of code that ruff flags (e.g. an unused import). Confirm CI fails on lint, not on tests. +- Why should you run lint before tests? (Hint: think about which check is cheaper.) diff --git a/data-track/week-5/exercise_5/solutions/ci.yml b/data-track/week-5/exercise_5/solutions/ci.yml new file mode 100644 index 0000000..abbb79f --- /dev/null +++ b/data-track/week-5/exercise_5/solutions/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # TODO 1: Add a step to set up Python 3.11. + # WHY pin the version: CI runners have a default Python version that can differ + # from your laptop. Pinning to 3.11 guarantees the same interpreter locally and + # in CI, so version-specific behaviour is not a source of surprises. + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # TODO 2: Add a step to install pytest. + # WHY not pip install -r requirements.txt: smoke tests often only need pytest. + # Installing just pytest keeps CI fast. Once you add application dependencies, + # switch to `pip install -r requirements.txt` so all imports resolve correctly. + - name: Install pytest + run: pip install pytest + + # TODO 3: Add a step to run pytest. + # WHY -q: quiet mode prints only failures and a summary line. Verbose output + # makes it harder to spot the actual failure in CI logs. + - name: Run tests + run: pytest -q diff --git a/data-track/week-5/exercise_5/solutions/test_smoke.py b/data-track/week-5/exercise_5/solutions/test_smoke.py new file mode 100644 index 0000000..d0f80b2 --- /dev/null +++ b/data-track/week-5/exercise_5/solutions/test_smoke.py @@ -0,0 +1,14 @@ +def test_pipeline_imports(): + # TODO 1: Replace the placeholder below with a real import from your pipeline module. + # WHY start with assert True: a smoke test's job is to confirm the test runner can + # execute. Once CI is wired and green, you replace this with a real import or a + # minimal behaviour check. Starting with a passing assertion means your first CI + # run is green, and you can verify the failure in step 3 by breaking the test + # intentionally — rather than debugging a broken environment at the same time. + assert True + + +# Step 3 — breaking the test to confirm CI fails: +# Change the line above to `assert False` and push. Watch CI fail. +# Then revert to `assert True` and push again. CI goes green. +# This confirms that your workflow actually reports failures — not just that it runs. diff --git a/data-track/week-5/exercise_5/tests/test_smoke.py b/data-track/week-5/exercise_5/tests/test_smoke.py new file mode 100644 index 0000000..c3eb29b --- /dev/null +++ b/data-track/week-5/exercise_5/tests/test_smoke.py @@ -0,0 +1,5 @@ +def test_pipeline_imports(): + # TODO 1: Replace the placeholder below with a real import from your pipeline module. + # For now, asserting True is enough to make the smoke test pass. + # The goal is to confirm CI can run pytest — not to test complex logic. + assert True diff --git a/data-track/week-5/exercise_6/.env.example b/data-track/week-5/exercise_6/.env.example new file mode 100644 index 0000000..7f741a5 --- /dev/null +++ b/data-track/week-5/exercise_6/.env.example @@ -0,0 +1,2 @@ +API_KEY=your-api-key-here +LOG_LEVEL=DEBUG diff --git a/data-track/week-5/exercise_6/Dockerfile b/data-track/week-5/exercise_6/Dockerfile new file mode 100644 index 0000000..4c68710 --- /dev/null +++ b/data-track/week-5/exercise_6/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +# TODO 1: Add an ARG instruction for BUILD_SHA with no default value. +# ARG values are available during the build but NOT at runtime. +??? + +# TODO 2: Add a RUN instruction that prints the BUILD_SHA value during the build. +# Use: RUN echo "Building SHA: $BUILD_SHA" +??? + +COPY src/ src/ + +CMD ["python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_6/README.md b/data-track/week-5/exercise_6/README.md new file mode 100644 index 0000000..04bf770 --- /dev/null +++ b/data-track/week-5/exercise_6/README.md @@ -0,0 +1,58 @@ +# Exercise 6: Environment Variable Patterns + +Practice three ways to pass configuration to a container: single `-e` flag, `--env-file`, and build-time `ARG`. + +## Setup + +Copy `.env.example` to `.env` (the `.env` file is gitignored): + +```bash +cp .env.example .env +``` + +Build the starter image (complete the TODOs in `Dockerfile` first): + +```bash +docker build -t pipeline-practice:6.0 . +``` + +## Task + +1. Fill in the two TODOs in `Dockerfile`. + +2. Build with a `BUILD_SHA` argument and confirm it appears in the build log: + ```bash + docker build --build-arg BUILD_SHA=abc123 -t pipeline-practice:6.0 . + ``` + Look for `Building SHA: abc123` in the output. + +3. Run the container with a single `-e` flag: + ```bash + docker run --rm -e API_KEY=demo pipeline-practice:6.0 + ``` + Expected: `API key present: True` and `Log level: INFO` (the default). + +4. Run the container with `--env-file`: + ```bash + docker run --rm --env-file .env pipeline-practice:6.0 + ``` + Expected: `API key present: True` and `Log level: DEBUG` (from `.env`). + +5. Run **without** any `-e` flags: + ```bash + docker run --rm pipeline-practice:6.0 + ``` + Expected: `API key present: False` and `Log level: INFO`. + +6. Add `RUN echo $BUILD_SHA` after `CMD` in the Dockerfile. Build and run the container. Is `BUILD_SHA` visible at runtime? Why not? + +## Success criteria + +- `--build-arg BUILD_SHA=abc123` prints the SHA during build. +- `-e API_KEY=demo` makes `API key present: True`. +- `--env-file .env` picks up both `API_KEY` and `LOG_LEVEL`. +- `BUILD_SHA` is not available when the container runs (step 6). + +## Stretch + +- Try passing a secret with `-e SECRET=hunter2` and then running `docker history pipeline-practice:6.0`. Is the secret visible? What does this tell you about using `ENV SECRET=...` in a Dockerfile? diff --git a/data-track/week-5/exercise_6/solutions/Dockerfile b/data-track/week-5/exercise_6/solutions/Dockerfile new file mode 100644 index 0000000..f2e8591 --- /dev/null +++ b/data-track/week-5/exercise_6/solutions/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# TODO 1: Add an ARG instruction for BUILD_SHA with no default value. +# WHY ARG not ENV: ARG values are available only during the build phase. +# They do not appear in the final image's environment or in `docker inspect` output. +# This makes ARG safe for build-time metadata like a commit SHA: the value is used +# to label or log the build, then discarded. ENV values are baked into the image +# and visible to anyone who inspects it — wrong for secrets, acceptable for non-secret +# runtime config like APP_ENV=production. +ARG BUILD_SHA + +# TODO 2: Add a RUN instruction that prints the BUILD_SHA value during the build. +# WHY echo during build: this is a common debugging technique for verifying that +# build arguments are reaching the Dockerfile correctly. In production you would +# label the image instead: LABEL git-sha=$BUILD_SHA +RUN echo "Building SHA: $BUILD_SHA" + +COPY src/ src/ + +CMD ["python", "src/pipeline.py"] diff --git a/data-track/week-5/exercise_6/src/pipeline.py b/data-track/week-5/exercise_6/src/pipeline.py new file mode 100644 index 0000000..7dc25ba --- /dev/null +++ b/data-track/week-5/exercise_6/src/pipeline.py @@ -0,0 +1,7 @@ +import os + +api_key = os.environ.get("API_KEY", "missing") +log_level = os.environ.get("LOG_LEVEL", "INFO") + +print(f"API key present: {api_key != 'missing'}") +print(f"Log level: {log_level}") diff --git a/data-track/week-5/exercise_7/README.md b/data-track/week-5/exercise_7/README.md new file mode 100644 index 0000000..facb958 --- /dev/null +++ b/data-track/week-5/exercise_7/README.md @@ -0,0 +1,45 @@ +# Exercise 7: Image Tagging Strategy + +Practice tagging the same image with multiple identifiers and understand why explicit tags matter. + +## Setup + +You need the image from Exercise 1 (or any image you have built this week): + +```bash +docker build -t pipeline-practice:1.0 . +``` + +(Run from the `exercise_1/` folder, or use an image you already have.) + +## Task + +1. Tag the same image as `dev`, `staging`, and `prod`: + ```bash + docker tag pipeline-practice:1.0 pipeline-practice:dev + docker tag pipeline-practice:1.0 pipeline-practice:staging + docker tag pipeline-practice:1.0 pipeline-practice:prod + ``` + +2. Run `docker images pipeline-practice` and confirm all four tags (`1.0`, `dev`, `staging`, `prod`) show the same `IMAGE ID`. + +3. Answer the following questions: + - Two engineers both push `pipeline-practice:latest` to a shared registry an hour apart. What happens to the first engineer's image? + - Your CI system tags images with the Git commit SHA (e.g. `abc1234`). What advantage does this give you over tagging with `1.0`? + - You have tags `dev`, `staging`, and `prod` pointing at three different commit SHAs. A bug is reported in production. How do you find which SHA is currently deployed? + +4. Simulate a CI tag using a fake SHA: + ```bash + docker tag pipeline-practice:1.0 pipeline-practice:$(git rev-parse --short HEAD 2>/dev/null || echo "abc1234") + ``` + Run `docker images pipeline-practice` again and find the SHA tag. + +## Success criteria + +- `docker images pipeline-practice` shows at least four tags for the same Image ID. +- You can explain why commit SHA tags are more reliable than `latest` for deployments. + +## Stretch + +- Push one of your tags to a registry (ACR or Docker Hub) and verify it appears there. +- What does `docker rmi pipeline-practice:dev` do? Does it delete the underlying image? diff --git a/data-track/week-5/exercise_7/solutions/answers.md b/data-track/week-5/exercise_7/solutions/answers.md new file mode 100644 index 0000000..648f949 --- /dev/null +++ b/data-track/week-5/exercise_7/solutions/answers.md @@ -0,0 +1,21 @@ +# Exercise 7: Reference Answers + +## Question 3a: Two engineers push `latest` + +The second push overwrites the first. The registry stores only one image per tag name. The first engineer's `latest` is gone from the tag — the underlying layers may still exist if nothing else has been pushed over them, but there is no longer a way to refer to "the first engineer's image" by tag. This is why `latest` is unreliable for deployments: it is a moving target. + +## Question 3b: Commit SHA advantage over `1.0` + +A commit SHA is globally unique and immutable. `1.0` could be pushed again by anyone (or overwritten in CI). The SHA ties the image to a specific point in git history, so you can always answer: "what code is in this image?" and then `git log abc1234` or `git show abc1234` to inspect it. This traceability is essential when debugging a production incident: you can reproduce the exact environment by pulling the SHA-tagged image. + +## Question 3c: Finding which SHA is deployed + +Run `docker inspect /pipeline-practice:prod` and look at the `RepoTags` or check which SHA the `prod` tag resolves to. In Azure Container Registry, the portal shows each tag's digest and push timestamp. From there you can match the tag to a git commit with `git show `. + +## Task 4: Simulating a CI tag + +`git rev-parse --short HEAD` prints the short SHA of the current commit (e.g. `a3f8c12`). Using `$(...)` in a shell command runs it inline, so the docker tag becomes `pipeline-practice:a3f8c12`. This is exactly what a CI workflow does with `${{ github.sha }}` in GitHub Actions. + +## Stretch: `docker rmi pipeline-practice:dev` + +This removes only the **tag**, not the underlying image layers. The layers are still referenced by `pipeline-practice:1.0`, `staging`, and `prod`. Docker only removes the layers from disk when no tags or containers reference them any more. To verify: check that the Image ID still appears in `docker images` under another tag after the `rmi`.