From 513a5c623830d0300c80b0b448dd226b08638392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 15:09:21 +0200 Subject: [PATCH 01/13] Add design spec: identifiable container names Template-based naming of plugin containers in both default and reuse mode, encoding (project, service, branch, dirtag, worker) with a configurable name_template, PROJECT_NAME/COMPOSE_PROJECT_NAME project resolution, and a deterministic 255-char trim+checksum. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-18-identifiable-container-names-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-18-identifiable-container-names-design.md diff --git a/docs/superpowers/specs/2026-06-18-identifiable-container-names-design.md b/docs/superpowers/specs/2026-06-18-identifiable-container-names-design.md new file mode 100644 index 0000000..d2ddf05 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-identifiable-container-names-design.md @@ -0,0 +1,217 @@ +# Identifiable container names + +**Status:** approved design (2026-06-18) +**Scope:** how `pytest-testcontainers` names the Docker containers it starts. + +## Problem + +In the default (non-reuse) mode the plugin never names its containers, so +Docker assigns random names (`heuristic_cannon`, `nifty_ganguly`). They are +impossible to identify in `docker ps`. Only *reuse mode* names containers +today, and even there the name (`-tc--`) does not +encode which git branch or which working directory the container belongs to — +so two checkouts of the same project share a name and collide. + +We want every container the plugin starts to carry a readable, identifiable +name derived from the project, the service, the **git branch**, and the +**directory**, with the scheme **configurable** via a template. + +## Goals + +- Name containers in **both** modes (default *and* reuse). +- Encode `(project, service, branch, directory, worker)` so containers are + identifiable in `docker ps` and reuse is correctly scoped. +- Configurable through a **template string**. +- Honor external project-name env vars (`PROJECT_NAME`, `COMPOSE_PROJECT_NAME`). +- Respect Docker's 255-char container-name limit with a deterministic + trim+checksum. + +## Non-goals + +- Changing the per-worker (xdist) container model — `scope="session"` still + means per worker. Worker id stays in the name. +- Any single-shared-container behavior (that is a different product). + +## Name scheme & template + +Default template: + +``` +{project}-tc-{service}-{branch}-{dirtag}-{worker} +``` + +Example: `myproj-tc-psql-feature-x-3f9ac1b2-master` + +### Placeholders + +| Placeholder | Meaning | +|-------------|---------| +| `{project}` | Resolved project name (see precedence below). | +| `{service}` | Service slug (`psql`, `redis`, `mysql`, `mongo`, `rabbitmq`, or derived from the container class for `make_container`). | +| `{branch}` | Current git branch from `.git/HEAD` (no subprocess). Detached HEAD → 12-char commit SHA. Not a git repo → resolves to empty. | +| `{dir}` | Sanitized basename of the worktree root. Human-readable, **not** collision-proof (two dirs can share a basename). | +| `{dirtag}` | 8-hex `blake2s` of the worktree-root **absolute path**. Collision-proof disambiguator for "same branch checked out in two directories". | +| `{worker}` | xdist worker id (`master` when not under xdist). | +| `{rand}` | Random token, 4 hex chars (`secrets.token_hex(2)`). **Empty in reuse mode**; auto-appended in default mode when the template doesn't already contain it. | + +- Empty placeholders collapse cleanly: no `--`, no leading/trailing dashes. +- The literal `-tc-` marker is kept in the default template so containers are + easy to find/prune (`docker ps | grep -- -tc-`). +- `{dirtag}` is the default disambiguator (safe). Users who know their + directories differ can swap to `{dir}` for readability. + +## Configurability + +`name_template` precedence (CLI > env > pyproject > built-in default): + +1. `--testcontainers-name-template=` +2. `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=` +3. `pyproject.toml` → `[tool.pytest_testcontainers].name_template` +4. Built-in default (above) + +An unknown placeholder (e.g. `{foo}`) raises **`NameTemplateError`**, a new +`PytestTestcontainersError` subclass whose message lists the valid +placeholders. + +## Project-name precedence + +The `{project}` component is resolved by this chain (highest first): + +``` +--testcontainers-project (CLI flag) +PYTEST_TESTCONTAINERS_PROJECT (plugin's own env var) +PROJECT_NAME (generic env var) +COMPOSE_PROJECT_NAME (docker-compose's standard env var) +pyproject.toml [project].name +cwd directory basename (fallback; triggers the one-shot advisory) +``` + +Plugin-namespaced sources stay on top (most explicit intent). The two generic +env vars sit above the file-based sources. The existing one-shot stderr +advisory still fires only when we fall through to the literal `pytest-tc` +fallback (i.e. nothing in the chain matched and there is no cwd basename). + +## Two modes + +### Reuse mode +Template expanded with `{rand}` empty → **fully deterministic, byte-stable** +across runs. Find-or-create now scopes reuse per +`(project, service, branch, dir, worker)`. + +**Behavior change:** switching branch or worktree produces a *new* container; +the previous one lingers (stopped) until `--testcontainers-clean` prunes it. +This is intentional (per-branch / per-directory isolation) and must be +documented, including in a migration note — existing reused containers carry +the old `-tc--` name and will not be found after +upgrade, so a fresh container is created and the old one lingers. + +### Default mode +Same template, but a unique `{rand}` suffix is appended (automatically if the +template doesn't already include `{rand}`) → no collisions across overlapping +or leaked runs. **New behavior:** default-mode containers, previously unnamed, +now carry the readable name. No find-or-create — they remain ephemeral +(started, then stopped at teardown, with the existing atexit safety net). + +## Length safety: `finalize_name` + +Docker container names are capped at 255 chars (charset +`[a-zA-Z0-9][a-zA-Z0-9_.-]+`). Lift the existing trim+checksum from +`compose_name` into one helper that every name passes through (both modes, +after template expansion and — in default mode — after the `{rand}` suffix): + +1. Sanitize the whole string (invalid chars → `-`, collapse `-` runs, strip + leading non-alnum and trailing `-._`). +2. If `len > 255`: `name[:246] + "-" + blake2s(full_name, digest_size=4).hexdigest()`. + +The checksum hashes the **full untruncated name**, not just the discarded +tail — any difference anywhere changes the hash, so two long names sharing the +same first 246 chars but differing only deep in the tail still get distinct +results. It is fully deterministic (no randomness), which is required for +reuse-mode stability. Digest is bumped 2→4 bytes (4→8 hex) because branch +names make overflow common. + +## Code layout + +Dependency direction preserved (`reuse`/`makers` → `_internal`; `errors` is a +leaf). + +- **New** `_internal/git_identity.py` — pure, filesystem-read-only: + - `worktree_root(start: Path) -> Path` — walk up to the dir holding `.git` + (handles `.git` as a dir *and* as a file pointing to a worktree gitdir); + falls back to `start` when there is no repo. + - `current_branch(start: Path) -> str | None` — read `HEAD` from the + resolved gitdir; `ref: refs/heads/` → ``; raw SHA → short SHA + (detached); unreadable / bare → `None`. +- **`reuse.py`**: + - Extend project resolution with `PROJECT_NAME` + `COMPOSE_PROJECT_NAME`. + - Read `[tool.pytest_testcontainers].name_template` from pyproject (reuse the + existing upward pyproject walk). + - Replace `compose_name` / `reuse_name_for` internals with: resolve all + components → expand template → `finalize_name`. `{rand}` empty for reuse, + populated for default mode. The explicit `reuse_name=` maker override keeps + its current verbatim (sanitized-only) behavior. + - Add `finalize_name(name: str) -> str`. + - Add CLI/state for the name template (with reset hooks — see Testing). +- **`makers.py`** (`_make_generic`): compute a name in default mode too and + call `instance.with_name(name)` before start; keep find-or-create gated on + `reuse_on`. Default mode keeps atexit registration + teardown stop. +- **`plugin.py`**: add `--testcontainers-name-template` option and wire it into + reuse state (alongside the existing `--testcontainers-project`). +- **`errors.py`**: add `NameTemplateError`. + +## Errors + +- `NameTemplateError(PytestTestcontainersError)` — raised at name-resolution + time for an unknown placeholder; message enumerates valid placeholders. +- `ReuseConflictError` — unchanged. + +## Testing + +Unit (no Docker, `not docker_required`): + +- Template expansion for every placeholder, and the empty-placeholder collapse. +- Project precedence across the full chain (CLI / `PYTEST_TESTCONTAINERS_PROJECT` + / `PROJECT_NAME` / `COMPOSE_PROJECT_NAME` / pyproject / cwd). +- `name_template` precedence (CLI / env / pyproject / default). +- Branch detection: attached ref, detached HEAD (short SHA), `.git`-file + worktree, and no-repo (→ empty). +- `dirtag` stable for a path and distinct across paths. +- `finalize_name`: ≤255 always, deterministic, distinct for names differing + only in the tail. +- Reuse name byte-stable across two calls; default-mode name carries `{rand}`, + reuse-mode name does not. +- Unknown placeholder → `NameTemplateError`. + +`docker_required` smoke: + +- A default-mode container actually carries the templated name + (`docker inspect`). +- Reuse identity differs across two branches. + +Test-isolation fix (important): `_clean_state` / `reset_cli_state` must also +clear `PROJECT_NAME`, `COMPOSE_PROJECT_NAME`, and the new template CLI/env +state. These vars are **not** `PYTEST_TESTCONTAINERS*`-prefixed, so the +conftest's existing env sweep does not catch them and they would leak across +tests. + +`filterwarnings = ["error", ...]` stays satisfied: advisories use the existing +stderr-write pattern, not `warnings.warn`. + +## Docs + +- **README**: `name_template`, placeholder table, both precedence chains, the + per-branch / per-directory reuse behavior, and the lingering-container note + (`--testcontainers-clean`). +- **SPEC.md**: behavior-defining entry for the template + finalize rules. +- **CLAUDE.md**: extend the configuration-precedence table with the new env + vars and the `--testcontainers-name-template` flag. + +## Backward compatibility + +Pre-1.0 (v0.1.0). Two visible changes: + +1. Default-mode containers are now named (previously random) — the desired fix. +2. Reuse-mode name format changes, so containers reused from a prior version + are not found and a fresh one is created; the old one lingers until + `--testcontainers-clean`. Document in the changelog and README migration + note. From d48c47beb5809aa77ed02b93c015a4eb4f47c31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:32:11 +0200 Subject: [PATCH 02/13] Add implementation plan: identifiable container names Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-18-identifiable-container-names.md | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-identifiable-container-names.md diff --git a/docs/superpowers/plans/2026-06-18-identifiable-container-names.md b/docs/superpowers/plans/2026-06-18-identifiable-container-names.md new file mode 100644 index 0000000..3f00a41 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-identifiable-container-names.md @@ -0,0 +1,1442 @@ +# Identifiable Container Names Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every container the plugin starts a readable, identifiable name derived from project + service + git branch + directory + worker, in both default and reuse mode, configurable via a template string. + +**Architecture:** A new pure `_internal/git_identity.py` reads `.git/HEAD` (no subprocess) to derive branch and worktree root. `reuse.py` resolves a `{placeholder}` template (CLI > env > pyproject > default), renders it with all components, and runs the result through a single `finalize_name` length/charset guard. `makers.py` now names containers in *both* modes (find-or-create stays gated on reuse). `plugin.py` adds `--testcontainers-name-template`; `errors.py` adds `NameTemplateError`. + +**Tech Stack:** Python 3.10–3.13, pytest 7/8, testcontainers-python ≥4.7, docker-py ≥6.1, `hashlib.blake2s`, `secrets`, `tomllib`/`tomli`. Managed with **uv**. + +## Global Constraints + +These apply to every task. Copied verbatim from the spec and CLAUDE.md: + +- **Line length 100.** Ruff selects `E,F,W,I,B,UP,SIM,RUF`; `E501` is ignored (formatter wraps). Tests get `B011` exempted. After any source/test edit run `uv run ruff check --fix src/ tests/` and `uv run ruff format src/ tests/`. +- **`filterwarnings = ["error", ...]`** — any new warning fails the suite. Advisories MUST use the existing `sys.stderr.write(...)` pattern, never `warnings.warn`. +- **No silent exception swallowing.** Every `except` logs, re-raises, raises another, or returns a meaningful value. Narrow suppression needs a justifying comment. +- **Public symbols come out of `__init__.py` only.** Never re-export from `_internal/` except the already-public `DbConnInfo`. +- **Layering is strict:** `plugin → fixtures → makers → containers → _internal`. `errors.py` is a leaf (anything may import it). `reuse.py`/`makers.py` may import from `_internal`. Do NOT introduce upward edges. +- **Version floors:** Python 3.10, pytest 7, testcontainers ≥4.7, docker-py ≥6.1. Don't use newer-than-floor APIs. +- **Branch:** all work lands on `feature/identifiable-container-names` (already checked out). Commit messages end with the trailer: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- **Docker container-name rules:** max 255 chars, charset `[a-zA-Z0-9][a-zA-Z0-9_.-]+`. Every assembled name passes through `finalize_name`. +- **Commands use uv:** `uv run pytest ...`, `uv run ruff ...`. Unit-only runs: `uv run pytest tests/ -m "not docker_required" -v`. + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `src/pytest_testcontainers/errors.py` | Exception hierarchy (leaf) | Add `NameTemplateError` | +| `src/pytest_testcontainers/__init__.py` | Public API surface | Export `NameTemplateError` | +| `src/pytest_testcontainers/_internal/git_identity.py` | Filesystem-only branch + worktree-root reads | **Create** | +| `src/pytest_testcontainers/reuse.py` | Project/template resolution, render, `finalize_name`, CLI state | Modify heavily | +| `src/pytest_testcontainers/makers.py` | Name containers in both modes | Modify `_make_generic` + import | +| `src/pytest_testcontainers/plugin.py` | `--testcontainers-name-template` flag + wiring | Modify | +| `tests/conftest.py` | Cross-test state reset | Add `PROJECT_NAME`, `COMPOSE_PROJECT_NAME`, `PYTEST_TESTCONTAINERS_NAME_TEMPLATE` to env sweep | +| `tests/test_git_identity.py` | Unit: branch/worktree detection | **Create** | +| `tests/test_reuse.py` | Unit: project precedence, template, finalize | Rewrite naming tests, add new | +| `tests/test_reuse_mode.py` | Reuse smoke | Update import only | +| `tests/test_makers_naming.py` | No-docker: maker names in both modes | **Create** | +| `tests/test_plugin_options.py` | Unit: CLI flag wires into reuse state | **Create** | +| `tests/test_naming_docker.py` | `docker_required` smoke: real name + per-branch identity | **Create** | +| `README.md`, `SPEC.md`, `CLAUDE.md` | Docs | Modify | + +--- + +## Task 1: `NameTemplateError` exception + +**Files:** +- Modify: `src/pytest_testcontainers/errors.py` +- Modify: `src/pytest_testcontainers/__init__.py` +- Test: `tests/test_errors.py` + +**Interfaces:** +- Produces: `class NameTemplateError(PytestTestcontainersError)` — raised at name-resolution time for an unknown template placeholder; importable from both `pytest_testcontainers.errors` and the top-level `pytest_testcontainers` package. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_errors.py`: + +```python +def test_name_template_error_is_plugin_error() -> None: + from pytest_testcontainers import NameTemplateError + from pytest_testcontainers.errors import NameTemplateError as ErrNameTemplateError + from pytest_testcontainers.errors import PytestTestcontainersError + + assert NameTemplateError is ErrNameTemplateError + assert issubclass(NameTemplateError, PytestTestcontainersError) + with pytest.raises(PytestTestcontainersError): + raise NameTemplateError("bad placeholder") +``` + +If `tests/test_errors.py` does not already `import pytest`, add it at the top. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_errors.py::test_name_template_error_is_plugin_error -v` +Expected: FAIL — `ImportError: cannot import name 'NameTemplateError'`. + +- [ ] **Step 3: Add the exception class** + +In `src/pytest_testcontainers/errors.py`, insert after the `ReuseConflictError` class (before `CleanSessionFixtureError`): + +```python +class NameTemplateError(PytestTestcontainersError): + """The configured container-name template referenced an unknown placeholder. + + Raised at name-resolution time (during fixture/maker setup, before any + container starts). The message enumerates the valid placeholders. + """ +``` + +- [ ] **Step 4: Export it from the package** + +In `src/pytest_testcontainers/__init__.py`, add `NameTemplateError` to the `from pytest_testcontainers.errors import (...)` block and to `__all__` (keep both alphabetical): + +```python +from pytest_testcontainers.errors import ( + CleanSessionFixtureError, + ContainerStartError, + DockerNotRunningError, + NameTemplateError, + PytestTestcontainersError, + ReuseConflictError, +) +``` + +and in `__all__` add `"NameTemplateError",` after `"DockerNotRunningError",`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/test_errors.py -v` +Expected: PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/errors.py src/pytest_testcontainers/__init__.py tests/test_errors.py +git commit -m "Add NameTemplateError exception + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `_internal/git_identity.py` — branch + worktree root + +**Files:** +- Create: `src/pytest_testcontainers/_internal/git_identity.py` +- Test: `tests/test_git_identity.py` + +**Interfaces:** +- Produces: + - `worktree_root(start: Path) -> Path` — nearest ancestor (inclusive) holding a `.git` entry (dir *or* file); falls back to `start.resolve()` when no `.git` is found. + - `current_branch(start: Path) -> str | None` — branch name from `HEAD`; detached HEAD → first 12 chars of the SHA; no repo / unreadable → `None`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_git_identity.py`: + +```python +"""Filesystem-only git identity: worktree root + current branch (no subprocess).""" + +from __future__ import annotations + +from pathlib import Path + +from pytest_testcontainers._internal.git_identity import current_branch, worktree_root + + +def _make_repo(root: Path, head: str) -> None: + """Create a normal-clone .git dir with a HEAD file at ``root``.""" + git = root / ".git" + git.mkdir(parents=True) + (git / "HEAD").write_text(head, encoding="utf-8") + + +def test_worktree_root_is_dir_holding_dot_git(tmp_path: Path) -> None: + _make_repo(tmp_path, "ref: refs/heads/main\n") + sub = tmp_path / "pkg" / "deep" + sub.mkdir(parents=True) + assert worktree_root(sub) == tmp_path.resolve() + + +def test_worktree_root_no_repo_falls_back_to_start(tmp_path: Path) -> None: + sub = tmp_path / "nope" + sub.mkdir() + assert worktree_root(sub) == sub.resolve() + + +def test_current_branch_attached_ref(tmp_path: Path) -> None: + _make_repo(tmp_path, "ref: refs/heads/feature/x\n") + assert current_branch(tmp_path) == "feature/x" + + +def test_current_branch_detached_head_short_sha(tmp_path: Path) -> None: + sha = "0123456789abcdef0123456789abcdef01234567" + _make_repo(tmp_path, sha + "\n") + assert current_branch(tmp_path) == sha[:12] + + +def test_current_branch_no_repo_is_none(tmp_path: Path) -> None: + assert current_branch(tmp_path) is None + + +def test_current_branch_linked_worktree_gitfile(tmp_path: Path) -> None: + # Real gitdir lives elsewhere; the worktree's .git is a file pointing to it. + real_gitdir = tmp_path / "realgit" + real_gitdir.mkdir() + (real_gitdir / "HEAD").write_text("ref: refs/heads/wt-branch\n", encoding="utf-8") + + wt = tmp_path / "wt" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {real_gitdir.resolve()}\n", encoding="utf-8") + + assert worktree_root(wt) == wt.resolve() + assert current_branch(wt) == "wt-branch" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_git_identity.py -v` +Expected: FAIL — `ModuleNotFoundError: ...git_identity`. + +- [ ] **Step 3: Create the module** + +Create `src/pytest_testcontainers/_internal/git_identity.py`: + +```python +"""Filesystem-only git identity helpers: worktree root + current branch. + +Pure reads — no subprocess, no docker, no import-time side effects. Used by +``reuse.py`` to compute the ``{branch}``, ``{dir}`` and ``{dirtag}`` name +components. Handles both a normal clone (``.git`` is a directory) and a +linked worktree (``.git`` is a file containing ``gitdir: ``). +""" + +from __future__ import annotations + +from pathlib import Path + + +def worktree_root(start: Path) -> Path: + """Return the directory that owns the working tree containing ``start``. + + Walks upward from ``start`` looking for a ``.git`` entry (a directory in a + normal clone, a file in a linked worktree — either marks the root). Falls + back to ``start.resolve()`` when no ``.git`` is found. + """ + current = start.resolve() + for candidate in [current, *current.parents]: + if (candidate / ".git").exists(): + return candidate + return current + + +def _gitdir_for(root: Path) -> Path | None: + """Resolve the gitdir that holds ``HEAD`` for the worktree at ``root``. + + Normal clone: ``/.git`` is a directory → that directory. + Linked worktree: ``/.git`` is a file ``gitdir: `` → ```` + (resolved relative to ``root`` when the recorded path is relative). + Returns ``None`` when neither shape is readable. + """ + dot_git = root / ".git" + if dot_git.is_dir(): + return dot_git + if dot_git.is_file(): + try: + content = dot_git.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + prefix = "gitdir:" + if content.startswith(prefix): + target = content[len(prefix) :].strip() + if target: + resolved = Path(target) + if not resolved.is_absolute(): + resolved = (root / resolved).resolve() + return resolved + return None + + +def current_branch(start: Path) -> str | None: + """Return the current git branch name, or ``None``. + + Reads ``HEAD`` from the resolved gitdir without spawning git: + - ``ref: refs/heads/`` → ```` + - any other ``ref: .../`` → last path component ```` + - raw SHA (detached HEAD) → first 12 chars + - unreadable / missing / empty → ``None`` + """ + root = worktree_root(start) + gitdir = _gitdir_for(root) + if gitdir is None: + return None + try: + content = (gitdir / "HEAD").read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + if not content: + return None + if content.startswith("ref:"): + ref = content[len("ref:") :].strip() + marker = "refs/heads/" + if marker in ref: + return ref.split(marker, 1)[1] or None + return ref.rsplit("/", 1)[-1] or None + return content[:12] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_git_identity.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/_internal/git_identity.py tests/test_git_identity.py +git commit -m "Add _internal/git_identity: branch + worktree-root reads + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Extend project-name precedence + +Adds `PROJECT_NAME` / `COMPOSE_PROJECT_NAME` env vars and a cwd-basename fallback to the project-name chain, and factors the pyproject loader so Task 4 can reuse it. The literal `pytest-tc` + advisory now fires only when there is no cwd basename (i.e. cwd is the filesystem root) — so the two existing fallback tests change. + +**Files:** +- Modify: `src/pytest_testcontainers/reuse.py` (`project_name`, new `_find_pyproject_data`, `_read_pyproject_project_name`, `_project_name_from_dir`) +- Modify: `tests/conftest.py` (env sweep) +- Test: `tests/test_reuse.py` (new precedence tests; update two fallback tests) + +**Interfaces:** +- Consumes: nothing new. +- Produces: + - `project_name() -> str` — precedence: `_cli_project` > `PYTEST_TESTCONTAINERS_PROJECT` > `PROJECT_NAME` > `COMPOSE_PROJECT_NAME` > pyproject `[project].name` > cwd basename > literal `"pytest-tc"` (advisory only on the literal). + - `_find_pyproject_data(start: Path) -> dict | None` — parsed nearest `pyproject.toml` walking up, or `None`. + - `_project_name_from_dir(cwd: Path) -> str` — the file/basename/literal tail (testable without chdir to `/`). + +- [ ] **Step 1: Extend the conftest env sweep** + +In `tests/conftest.py`, add three vars to the `for var in (...)` tuple inside `_clean_state` (these are NOT `PYTEST_TESTCONTAINERS*`-prefixed, so the sweep otherwise misses them and they leak): + +```python + for var in ( + "PYTEST_TESTCONTAINERS", + "PYTEST_TESTCONTAINERS_REUSE", + "PYTEST_TESTCONTAINERS_PROJECT", + "PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK", + "PYTEST_TESTCONTAINERS_QUIET", + "PYTEST_TESTCONTAINERS_NAME_TEMPLATE", + "PROJECT_NAME", + "COMPOSE_PROJECT_NAME", + ): + monkeypatch.delenv(var, raising=False) +``` + +(`PYTEST_TESTCONTAINERS_NAME_TEMPLATE` is added now so Task 4 needs no further conftest change.) + +- [ ] **Step 2: Write the failing tests** + +In `tests/test_reuse.py`, **replace** the two existing fallback tests (`test_project_name_fallback_emits_warning` and `test_project_name_fallback_warning_suppressed_by_quiet`) with the block below, and add the new precedence tests. Add `from pathlib import Path` to the imports at the top of the file. + +```python +def test_project_name_PROJECT_NAME_env(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("PROJECT_NAME", "from-project-name") + assert project_name() == "from-project-name" + + +def test_project_name_COMPOSE_PROJECT_NAME_env(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "from-compose") + assert project_name() == "from-compose" + + +def test_project_name_PROJECT_NAME_beats_compose(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("PROJECT_NAME", "wins") + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "loses") + assert project_name() == "wins" + + +def test_project_name_plugin_env_beats_PROJECT_NAME( + monkeypatch: pytest.MonkeyPatch, tmp_cwd +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "plugin-env") + monkeypatch.setenv("PROJECT_NAME", "generic-env") + assert project_name() == "plugin-env" + + +def test_project_name_PROJECT_NAME_beats_pyproject( + monkeypatch: pytest.MonkeyPatch, tmp_cwd +) -> None: + (tmp_cwd / "pyproject.toml").write_text( + textwrap.dedent("""\ + [project] + name = "from-pyproject" + """) + ) + monkeypatch.setenv("PROJECT_NAME", "from-project-name") + assert project_name() == "from-project-name" + + +def test_project_name_cwd_basename_fallback( + tmp_cwd, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # No pyproject, no env: fall back to the cwd directory basename, no warning. + app = tmp_cwd / "myapp" + app.mkdir() + monkeypatch.chdir(app) + assert project_name() == "myapp" + assert capsys.readouterr().err == "" + + +def test_project_name_literal_fallback_emits_warning( + capsys: pytest.CaptureFixture[str], +) -> None: + # Filesystem root has an empty basename → literal fallback + one-shot warning. + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert "fallback project name" in capsys.readouterr().err + # one-shot: second call is silent + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert capsys.readouterr().err == "" + + +def test_project_name_literal_fallback_suppressed_by_quiet( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_QUIET", "1") + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert capsys.readouterr().err == "" +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/test_reuse.py -k "project_name" -v` +Expected: FAIL — `_project_name_from_dir` does not exist; `PROJECT_NAME`/`COMPOSE_PROJECT_NAME` are ignored. + +- [ ] **Step 4: Refactor the pyproject loader and project resolution** + +In `src/pytest_testcontainers/reuse.py`, **replace** the entire `_read_pyproject_project_name` function with a factored loader plus a thin reader: + +```python +def _find_pyproject_data(start: Path) -> dict | None: + """Return parsed data of the nearest ``pyproject.toml`` walking up from ``start``. + + The first ``pyproject.toml`` found wins (even if it lacks the field a + caller wants). Returns ``None`` when none is reachable or parsing fails. + """ + try: + if sys.version_info >= (3, 11): + import tomllib # type: ignore[import-not-found] + else: # pragma: no cover — exercised on 3.10 only + import tomli as tomllib # type: ignore[import-not-found] + except ImportError: # pragma: no cover — tomli is a dep on 3.10 + return None + + current = start.resolve() + for candidate in [current, *current.parents]: + pyproject = candidate / "pyproject.toml" + if not pyproject.is_file(): + continue + try: + with pyproject.open("rb") as fh: + return tomllib.load(fh) + except (OSError, ValueError): + return None + return None + + +def _read_pyproject_project_name(start: Path) -> str | None: + """Read ``[project].name`` from the nearest ``pyproject.toml``.""" + data = _find_pyproject_data(start) + if data is None: + return None + project = data.get("project", {}) + name = project.get("name") if isinstance(project, dict) else None + if isinstance(name, str) and name.strip(): + return name.strip() + return None +``` + +Then **replace** the `project_name` function with the extended chain plus the testable tail: + +```python +def project_name() -> str: + """Resolve the ``{project}`` component used in container names. + + Precedence (most explicit first): CLI override, the plugin's own + ``PYTEST_TESTCONTAINERS_PROJECT``, the generic ``PROJECT_NAME`` then + ``COMPOSE_PROJECT_NAME`` env vars, ``pyproject.toml [project].name``, the + cwd basename, and finally the literal ``"pytest-tc"`` (which triggers the + one-shot stderr advisory). + """ + if _cli_project: + return _cli_project + env = os.environ.get("PYTEST_TESTCONTAINERS_PROJECT") + if env: + return env + generic = os.environ.get("PROJECT_NAME") or os.environ.get("COMPOSE_PROJECT_NAME") + if generic: + return generic + return _project_name_from_dir(Path.cwd()) + + +def _project_name_from_dir(cwd: Path) -> str: + """File/basename/literal tail of :func:`project_name` (separately testable).""" + found = _read_pyproject_project_name(cwd) + if found: + return found + basename = cwd.name + if basename: + return basename + _emit_fallback_warning() + return "pytest-tc" +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_reuse.py -k "project_name" -v` +Expected: PASS (all project_name tests, including the new precedence + fallback ones). + +- [ ] **Step 6: Run the full reuse + conftest sanity check** + +Run: `uv run pytest tests/test_reuse.py -v` +Expected: the `compose_name` / `reuse_name_for` tests still PASS here (untouched until Task 4); project_name tests PASS. + +- [ ] **Step 7: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/reuse.py tests/conftest.py tests/test_reuse.py +git commit -m "Extend project-name precedence: PROJECT_NAME, COMPOSE_PROJECT_NAME, cwd basename + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Template machinery — render + finalize + `container_name_for` + +Replaces `compose_name` / `reuse_name_for` with template rendering. Adds `name_template()`, `finalize_name()`, `container_name_for()`, the `{placeholder}` renderer, and the name-template CLI state. This is where the default template, both-mode `{rand}` handling, and the 255-char guard live. + +**Files:** +- Modify: `src/pytest_testcontainers/reuse.py` +- Modify: `tests/test_reuse_mode.py` (import + call rename only) +- Test: `tests/test_reuse.py` (replace `compose_name`/`reuse_name_for` tests with template/finalize/`container_name_for` tests) + +**Interfaces:** +- Consumes: `git_identity.worktree_root`, `git_identity.current_branch`, `errors.NameTemplateError`, `project_name`, `worker_id`, `sanitize_component`, `_find_pyproject_data` (Task 3). +- Produces: + - `DEFAULT_NAME_TEMPLATE = "{project}-tc-{service}-{branch}-{dirtag}-{worker}"` + - `VALID_PLACEHOLDERS: frozenset[str]` = `{project, service, branch, dir, dirtag, worker, rand}` + - `name_template() -> str` — CLI > `PYTEST_TESTCONTAINERS_NAME_TEMPLATE` > pyproject `[tool.pytest_testcontainers].name_template` > `DEFAULT_NAME_TEMPLATE`. + - `finalize_name(name: str) -> str` — sanitize whole string; if >255, `sanitized[:246] + "-" + blake2s(name, digest_size=4).hexdigest()`. + - `container_name_for(service: str, *, override: str | None = None, reuse_mode: bool = True) -> str` — override wins (sanitized only); else render template + finalize. `{rand}` empty when `reuse_mode`, a fresh `secrets.token_hex(2)` otherwise (auto-appended when the template omits `{rand}`). + - `set_cli_name_template(value: str | None) -> None`; `reset_cli_state()` also clears it. + - Removed: `compose_name`, `reuse_name_for`. + +- [ ] **Step 1: Update the reuse-smoke import (keep it green later)** + +In `tests/test_reuse_mode.py` change the import and the call: + +```python +from pytest_testcontainers.reuse import container_name_for +``` + +and inside `test_reuse_keeps_same_container_id`: + +```python + name = container_name_for("redis") +``` + +(`reuse_mode` defaults to `True`, so this matches the name the maker computes in reuse mode.) + +- [ ] **Step 2: Write the failing unit tests** + +In `tests/test_reuse.py`, **remove** `compose_name` from the import block and **delete** `test_compose_name_short`, `test_compose_name_truncates_when_long`, `test_reuse_name_for_uses_override`, `test_reuse_name_for_composes`, and `test_reuse_name_for_with_xdist_worker`. Update the import block to: + +```python +from pytest_testcontainers import reuse +from pytest_testcontainers.errors import NameTemplateError +from pytest_testcontainers.reuse import ( + DEFAULT_NAME_TEMPLATE, + container_name_for, + finalize_name, + is_plugin_disabled, + is_reuse_enabled, + name_template, + project_name, + sanitize_component, + worker_id, +) +``` + +Then add: + +```python +# --- template rendering ------------------------------------------------- + +def test_render_expands_all_placeholders() -> None: + values = { + "project": "myproj", + "service": "psql", + "branch": "feature-x", + "dir": "checkout", + "dirtag": "3f9ac1b2", + "worker": "gw0", + "rand": "ab12", + } + out = reuse._render_template( + "{project}-tc-{service}-{branch}-{dir}-{dirtag}-{worker}-{rand}", values + ) + assert out == "myproj-tc-psql-feature-x-checkout-3f9ac1b2-gw0-ab12" + + +def test_render_unknown_placeholder_raises() -> None: + with pytest.raises(NameTemplateError) as exc: + reuse._render_template("{project}-{nope}", {"project": "p"}) + assert "nope" in str(exc.value) + assert "project" in str(exc.value) # message lists valid placeholders + + +def test_finalize_collapses_empty_branch() -> None: + # An empty {branch} leaves a double dash that finalize collapses cleanly. + rendered = reuse._render_template( + DEFAULT_NAME_TEMPLATE, + { + "project": "myproj", + "service": "psql", + "branch": "", + "dir": "d", + "dirtag": "3f9ac1b2", + "worker": "master", + "rand": "", + }, + ) + assert finalize_name(rendered) == "myproj-tc-psql-3f9ac1b2-master" + + +# --- finalize_name length safety --------------------------------------- + +def test_finalize_name_short_passthrough() -> None: + assert finalize_name("MyProj-TC-Psql-Master") == "myproj-tc-psql-master" + + +def test_finalize_name_caps_at_255() -> None: + out = finalize_name("x" * 400) + assert len(out) <= 255 + + +def test_finalize_name_deterministic() -> None: + long = "y" * 400 + assert finalize_name(long) == finalize_name(long) + + +def test_finalize_name_distinguishes_tail() -> None: + base = "z" * 300 + a = finalize_name(base + "-alpha") + b = finalize_name(base + "-omega") + assert a != b # hash over the full name, not just the kept prefix + + +# --- name_template precedence ------------------------------------------ + +def test_name_template_default(tmp_cwd) -> None: + assert name_template() == DEFAULT_NAME_TEMPLATE + + +def test_name_template_env_over_pyproject(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_cwd / "pyproject.toml").write_text( + textwrap.dedent("""\ + [tool.pytest_testcontainers] + name_template = "{service}-from-pyproject" + """) + ) + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-from-env") + assert name_template() == "{service}-from-env" + + +def test_name_template_pyproject(tmp_cwd) -> None: + (tmp_cwd / "pyproject.toml").write_text( + textwrap.dedent("""\ + [tool.pytest_testcontainers] + name_template = "{service}-from-pyproject" + """) + ) + assert name_template() == "{service}-from-pyproject" + + +def test_name_template_cli_wins(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-env") + reuse.set_cli_name_template("{service}-cli") + assert name_template() == "{service}-cli" + + +# --- container_name_for end to end ------------------------------------- + +def test_container_name_for_override(tmp_cwd) -> None: + assert container_name_for("psql", override="MY_NAME") == "my_name" + + +def test_container_name_for_reuse_is_byte_stable( + tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{worker}") + monkeypatch.setenv("PROJECT_NAME", "myproj") + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + first = container_name_for("psql", reuse_mode=True) + second = container_name_for("psql", reuse_mode=True) + assert first == second == "myproj-tc-psql-master" + + +def test_container_name_for_default_appends_rand( + tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{worker}") + monkeypatch.setenv("PROJECT_NAME", "myproj") + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + name = container_name_for("psql", reuse_mode=False) + assert re.match(r"^myproj-tc-psql-master-[0-9a-f]{4}$", name) + + +def test_container_name_for_explicit_rand_not_double_appended( + tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-{rand}") + name = container_name_for("psql", reuse_mode=False) + assert re.match(r"^psql-[0-9a-f]{4}$", name) # exactly one rand token +``` + +Add `import re` to the top of `tests/test_reuse.py` (alongside `textwrap`). + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/test_reuse.py -v` +Expected: FAIL — `ImportError` for `DEFAULT_NAME_TEMPLATE` / `container_name_for` / `finalize_name` / `name_template`. + +- [ ] **Step 4: Add imports + CLI state to `reuse.py`** + +At the top of `src/pytest_testcontainers/reuse.py`, add `import secrets` (keep imports sorted: it goes after `import re`). Add the cross-module imports after the existing stdlib imports: + +```python +from pytest_testcontainers._internal import git_identity +from pytest_testcontainers.errors import NameTemplateError +``` + +Add the CLI state variable next to `_cli_project` (in the CLI-overrides block near the top): + +```python +_cli_name_template: str | None = None +``` + +Add its setter next to `set_cli_project`: + +```python +def set_cli_name_template(value: str | None) -> None: + global _cli_name_template + with _cli_state_lock: + _cli_name_template = value +``` + +Update `reset_cli_state` to clear it: + +```python +def reset_cli_state() -> None: + """Used in tests to keep cross-test state from leaking.""" + global _cli_project, _cli_reuse, _cli_disabled, _fallback_warned, _cli_name_template + with _cli_state_lock: + _cli_project = None + _cli_reuse = None + _cli_disabled = None + _fallback_warned = False + _cli_name_template = None +``` + +- [ ] **Step 5: Add template constants, reader, render, finalize, and `container_name_for`** + +In `src/pytest_testcontainers/reuse.py`, **replace** the whole "Container-name sanitization + composition" section — i.e. `compose_name` and `reuse_name_for` (keep `sanitize_component` and the `_INVALID_CHAR` / `_RUN_OF_DASH` / `_LEADING_NON_ALNUM` regexes) — with: + +```python +DEFAULT_NAME_TEMPLATE = "{project}-tc-{service}-{branch}-{dirtag}-{worker}" +VALID_PLACEHOLDERS = frozenset( + {"project", "service", "branch", "dir", "dirtag", "worker", "rand"} +) +_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}") + + +def _read_pyproject_name_template(start: Path) -> str | None: + """Read ``[tool.pytest_testcontainers].name_template`` from pyproject.""" + data = _find_pyproject_data(start) + if data is None: + return None + tool = data.get("tool", {}) + table = tool.get("pytest_testcontainers", {}) if isinstance(tool, dict) else {} + template = table.get("name_template") if isinstance(table, dict) else None + if isinstance(template, str) and template.strip(): + return template.strip() + return None + + +def name_template() -> str: + """Resolve the container-name template (CLI > env > pyproject > default).""" + if _cli_name_template: + return _cli_name_template + env = os.environ.get("PYTEST_TESTCONTAINERS_NAME_TEMPLATE") + if env: + return env + found = _read_pyproject_name_template(Path.cwd()) + if found: + return found + return DEFAULT_NAME_TEMPLATE + + +def _render_template(template: str, values: dict[str, str]) -> str: + """Substitute ``{placeholder}`` tokens; unknown placeholder → error.""" + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + if key not in values: + valid = ", ".join("{" + p + "}" for p in sorted(VALID_PLACEHOLDERS)) + raise NameTemplateError( + f"unknown placeholder {{{key}}} in container name template; " + f"valid placeholders are: {valid}" + ) + return values[key] + + return _PLACEHOLDER_RE.sub(_replace, template) + + +def _build_values(service: str, *, rand: str) -> dict[str, str]: + """Resolve every placeholder value for the current cwd / git state.""" + cwd = Path.cwd() + root = git_identity.worktree_root(cwd) + dirtag = hashlib.blake2s(str(root).encode(), digest_size=4).hexdigest() + return { + "project": project_name(), + "service": service, + "branch": git_identity.current_branch(cwd) or "", + "dir": root.name, + "dirtag": dirtag, + "worker": worker_id(), + "rand": rand, + } + + +def finalize_name(name: str) -> str: + """Make ``name`` Docker-safe and ≤255 chars (deterministic trim+checksum).""" + sanitized = sanitize_component(name) + if len(sanitized) <= 255: + return sanitized + digest = hashlib.blake2s(name.encode(), digest_size=4).hexdigest() + return sanitized[:246] + "-" + digest + + +def container_name_for( + service: str, *, override: str | None = None, reuse_mode: bool = True +) -> str: + """Compute the container name for ``service``. + + ``override`` (the maker ``reuse_name=`` argument) wins verbatim, sanitized + but not templated. Otherwise the configured template is rendered and + finalized. In reuse mode ``{rand}`` is empty so the name is byte-stable for + find-or-create; in default mode a fresh ``secrets.token_hex(2)`` is used + (auto-appended when the template does not already contain ``{rand}``) so + overlapping or leaked runs never collide. + """ + if override is not None: + return sanitize_component(override) + template = name_template() + rand = "" if reuse_mode else secrets.token_hex(2) + rendered = _render_template(template, _build_values(service, rand=rand)) + if not reuse_mode and "{rand}" not in template: + rendered = f"{rendered}-{rand}" + return finalize_name(rendered) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/test_reuse.py -v` +Expected: PASS (all template / finalize / precedence / `container_name_for` tests). + +- [ ] **Step 7: Confirm nothing else imports the removed names** + +Run: `grep -rn "compose_name\|reuse_name_for" src/ tests/` +Expected: only `src/pytest_testcontainers/makers.py` still references `reuse_name_for` (fixed in Task 5). If anything else appears, it must be updated before continuing. + +- [ ] **Step 8: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/reuse.py tests/test_reuse.py tests/test_reuse_mode.py +git commit -m "Add template-based container naming: render, finalize_name, container_name_for + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Name containers in both modes (`makers.py`) + +`_make_generic` now computes a name unconditionally and applies it via `with_name` in default mode too. Find-or-create and Ryuk-disable stay gated on `reuse_on`; the atexit safety net and teardown stop stay gated on `not reuse_on`. + +**Files:** +- Modify: `src/pytest_testcontainers/makers.py` +- Test: `tests/test_makers_naming.py` (create) + +**Interfaces:** +- Consumes: `reuse.container_name_for` (Task 4), `reuse.is_reuse_enabled`, `reuse.sanitize_component`. +- Produces: every maker-started container is named; default-mode names carry a `{rand}` suffix, reuse-mode names are deterministic. + +- [ ] **Step 1: Write the failing no-docker tests** + +Create `tests/test_makers_naming.py`: + +```python +"""Maker naming with the daemon ping and start mocked out (no real Docker).""" + +from __future__ import annotations + +import re + +import pytest + +from pytest_testcontainers import makers +from pytest_testcontainers.makers import make_container +from pytest_testcontainers.reuse import container_name_for + + +class _Fake: + def __init__(self, *args, **kwargs) -> None: + self.assigned_name: str | None = None + + def with_name(self, name): + self.assigned_name = name + return self + + def with_env(self, *args, **kwargs): + return self + + def stop(self): + pass + + +@pytest.fixture +def _stub_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(makers, "check_docker_daemon", lambda: None) + monkeypatch.setattr(makers, "start_or_raise", lambda instance, image: None) + + +def test_default_mode_assigns_templated_name( + _stub_runtime, tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + captured: dict[str, str] = {} + + class Fake(_Fake): + def with_name(self, name): + captured["name"] = name + return super().with_name(name) + + with make_container(Fake): + pass + + name = captured["name"] + assert "-tc-fake-" in name # service slug derived from the class name + assert re.search(r"-[0-9a-f]{4}$", name) # default mode appends a rand token + + +def test_reuse_mode_assigns_deterministic_name( + _stub_runtime, tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_REUSE", "1") + monkeypatch.setattr(makers, "find_existing_container", lambda name: None) + monkeypatch.setattr(makers, "disable_ryuk_once", lambda: None) + captured: dict[str, str] = {} + + class Fake(_Fake): + def with_name(self, name): + captured["name"] = name + return super().with_name(name) + + expected = container_name_for("fake", reuse_mode=True) + with make_container(Fake): + pass + + assert captured["name"] == expected +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_makers_naming.py -v` +Expected: FAIL — `ImportError: cannot import name 'container_name_for'` is already fixed, but the maker still imports `reuse_name_for`, so `make_container` raises `ImportError` at import / the default-mode container is never named. + +- [ ] **Step 3: Update the maker import** + +In `src/pytest_testcontainers/makers.py`, change the `from pytest_testcontainers.reuse import (...)` block: + +```python +from pytest_testcontainers.reuse import ( + container_name_for, + is_reuse_enabled, + sanitize_component, +) +``` + +- [ ] **Step 4: Name in both modes inside `_make_generic`** + +In `_make_generic`, replace the reuse decision + name line: + +```python + reuse_on = is_reuse_enabled() or reuse_name is not None + name = container_name_for(service_slug, override=reuse_name, reuse_mode=reuse_on) +``` + +Change the find-or-create guard from `if reuse_on and name is not None:` to: + +```python + if reuse_on: +``` + +In the fresh-start branch, drop the `if name is not None:` guard so default mode is named too: + +```python + if instance is None: + instance = container_cls(*constructor_args, **constructor_kwargs) + instance.with_name(name) + _apply_env(instance, env) + + image_for_msg = constructor_kwargs.get("image") or ( + constructor_args[0] if constructor_args else container_cls.__name__ + ) + start_or_raise(instance, str(image_for_msg)) + + if not reuse_on: + register_atexit_stop(instance) +``` + +(The teardown block is unchanged — it already stops only when `not (reuse_on or is_bound_to_existing)`.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_makers_naming.py tests/test_makers_no_docker.py -v` +Expected: PASS (new naming tests + the existing daemon-not-running test still green). + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/makers.py tests/test_makers_naming.py +git commit -m "Name containers in both default and reuse mode + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: `--testcontainers-name-template` CLI flag + +**Files:** +- Modify: `src/pytest_testcontainers/plugin.py` +- Test: `tests/test_plugin_options.py` (create) + +**Interfaces:** +- Consumes: `reuse.set_cli_name_template`, `reuse.name_template` (Task 4). +- Produces: `--testcontainers-name-template=` sets the highest-precedence template via `reuse.set_cli_name_template` in `pytest_configure`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_plugin_options.py`: + +```python +"""Plugin CLI option wiring (no pytester — exercise the hooks directly).""" + +from __future__ import annotations + +from pytest_testcontainers import plugin, reuse + + +class _StubConfig: + """Minimal duck-typed pytest.Config exposing getoption().""" + + def __init__(self, options: dict[str, object]) -> None: + self._options = options + + def getoption(self, name: str): + return self._options.get(name) + + +def _base_options(**overrides: object) -> dict[str, object]: + opts: dict[str, object] = { + "testcontainers_disabled": False, + "testcontainers_project": None, + "testcontainers_no_reuse": False, + "testcontainers_reuse": False, + "testcontainers_name_template": None, + } + opts.update(overrides) + return opts + + +def test_name_template_flag_wires_into_reuse_state() -> None: + config = _StubConfig(_base_options(testcontainers_name_template="{service}-from-cli")) + plugin.pytest_configure(config) + assert reuse.name_template() == "{service}-from-cli" + + +def test_no_name_template_flag_leaves_default(tmp_cwd) -> None: + config = _StubConfig(_base_options()) + plugin.pytest_configure(config) + assert reuse.name_template() == reuse.DEFAULT_NAME_TEMPLATE +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_plugin_options.py -v` +Expected: FAIL — `getoption("testcontainers_name_template")` returns `None`/unhandled and `name_template()` is not set (first test fails on the assertion). + +- [ ] **Step 3: Register the option** + +In `src/pytest_testcontainers/plugin.py`, inside `pytest_addoption`, add after the `--testcontainers-project` option: + +```python + group.addoption( + "--testcontainers-name-template", + action="store", + default=None, + dest="testcontainers_name_template", + help=( + "Template for container names. Placeholders: {project} {service} " + "{branch} {dir} {dirtag} {worker} {rand}." + ), + ) +``` + +- [ ] **Step 4: Wire it in `pytest_configure`** + +In `pytest_configure`, add after the `testcontainers_project` handling: + +```python + name_template = config.getoption("testcontainers_name_template") + if name_template: + reuse.set_cli_name_template(name_template) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/test_plugin_options.py -v` +Expected: PASS (both tests). + +- [ ] **Step 6: Lint + commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add src/pytest_testcontainers/plugin.py tests/test_plugin_options.py +git commit -m "Add --testcontainers-name-template CLI flag + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Docker smoke tests (real names + per-branch identity) + +**Files:** +- Test: `tests/test_naming_docker.py` (create) + +**Interfaces:** +- Consumes: `make_redis`, `reuse.container_name_for`, docker-py. Marked `@pytest.mark.docker_required` (auto-skipped without a daemon). + +- [ ] **Step 1: Write the smoke tests** + +Create `tests/test_naming_docker.py`: + +```python +"""docker_required: a default-mode container really carries the templated name, +and reuse identity differs across git branches.""" + +from __future__ import annotations + +import pytest + +from pytest_testcontainers import make_redis +from pytest_testcontainers.reuse import container_name_for + +pytestmark = pytest.mark.docker_required + + +def _remove_named(name: str) -> None: + import docker + from docker.errors import NotFound + + client = docker.from_env() + try: + client.containers.get(name).remove(force=True) + except NotFound: + return + + +def test_default_mode_container_has_templated_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "ptc-name-smoke") + with make_redis() as r: + docker_name = r._container.name + assert docker_name.startswith("ptc-name-smoke-tc-redis-") + + +def test_reuse_identity_differs_across_branches(monkeypatch: pytest.MonkeyPatch) -> None: + # The {branch} component is part of the reuse identity, so two branches + # resolve to two distinct container names. + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "ptc-branch-smoke") + monkeypatch.setenv( + "PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{branch}" + ) + monkeypatch.setenv("PYTEST_TESTCONTAINERS_REUSE", "1") + + monkeypatch.setattr( + "pytest_testcontainers.reuse.git_identity.current_branch", lambda start: "branch-a" + ) + name_a = container_name_for("redis") + + monkeypatch.setattr( + "pytest_testcontainers.reuse.git_identity.current_branch", lambda start: "branch-b" + ) + name_b = container_name_for("redis") + + assert name_a != name_b + assert name_a == "ptc-branch-smoke-tc-redis-branch-a" + assert name_b == "ptc-branch-smoke-tc-redis-branch-b" + + _remove_named("ptc-name-smoke-tc-redis") # defensive: no leak from sibling test +``` + +Note: the second test is pure-resolution and would pass without Docker, but it is grouped here under `docker_required` because it documents reuse-identity behavior alongside the real-container smoke. Keep it in this file for cohesion. + +- [ ] **Step 2: Run the smoke tests (skips cleanly without Docker)** + +Run: `uv run pytest tests/test_naming_docker.py -v` +Expected: PASS when a Docker daemon is reachable; SKIPPED otherwise (conftest auto-skip). Both outcomes are acceptable for this step. + +- [ ] **Step 3: Commit** + +```bash +uv run ruff check --fix src/ tests/ && uv run ruff format src/ tests/ +git add tests/test_naming_docker.py +git commit -m "Add docker_required naming smoke tests + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Full suite + docs + +Run the whole unit suite green, then document the feature. + +**Files:** +- Modify: `README.md`, `SPEC.md`, `CLAUDE.md` + +- [ ] **Step 1: Run the full unit suite + lint gate (must be green before docs)** + +```bash +uv run pytest tests/ -m "not docker_required" -v +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +``` + +Expected: all unit tests PASS; ruff reports no issues. Fix anything red before continuing. + +- [ ] **Step 2: README — Configuration env-var table** + +In `README.md`, in the `### Environment variables` table (around line 428), add three rows after the `PYTEST_TESTCONTAINERS_PROJECT` row: + +```markdown +| `PROJECT_NAME=` | Generic project-name source (below the plugin var). | +| `COMPOSE_PROJECT_NAME=` | docker-compose's project var (below `PROJECT_NAME`). | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=` | Override the container-name template. | +``` + +- [ ] **Step 3: README — Configuration CLI-flags table** + +In the `### CLI flags` table (around line 438), add a row after `--testcontainers-project=NAME`: + +```markdown +| `--testcontainers-name-template=T` | `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=T` | +``` + +- [ ] **Step 4: README — new "Container names" subsection** + +Insert a new subsection in `README.md` immediately before `## Reuse mode` (line 386): + +````markdown +## Container names + +Every container the plugin starts is named so you can find it in `docker ps` +(previously default-mode containers got random Docker names like +`heuristic_cannon`). The name is built from a template: + +``` +{project}-tc-{service}-{branch}-{dirtag}-{worker} +``` + +e.g. `myproj-tc-psql-feature-x-3f9ac1b2-master`. + +Placeholders: + +| Placeholder | Meaning | +|-------------|---------| +| `{project}` | Project name (see precedence below). | +| `{service}` | Service slug (`psql`, `redis`, `mysql`, `mongo`, `rabbitmq`, or derived from the container class). | +| `{branch}` | Current git branch (read from `.git/HEAD`, no subprocess). Detached HEAD → 12-char commit SHA. No repo → empty. | +| `{dir}` | Worktree-root basename. Readable, not collision-proof. | +| `{dirtag}` | 8-hex hash of the worktree-root absolute path. Disambiguates the same branch checked out in two directories. | +| `{worker}` | xdist worker id (`master` when not under xdist). | +| `{rand}` | 4 random hex chars. Empty in reuse mode; auto-appended in default mode when the template omits it. | + +Empty placeholders collapse cleanly (no `--`, no dangling dashes). Names are +sanitized to Docker's charset and capped at 255 chars (trim + checksum). + +Override the template (CLI > env > pyproject > built-in default): + +```bash +pytest --testcontainers-name-template='{project}-{service}-{branch}' +# or +export PYTEST_TESTCONTAINERS_NAME_TEMPLATE='{project}-{service}-{branch}' +``` + +```toml +# pyproject.toml +[tool.pytest_testcontainers] +name_template = "{project}-{service}-{branch}" +``` + +The `{project}` component resolves as: `--testcontainers-project` → +`PYTEST_TESTCONTAINERS_PROJECT` → `PROJECT_NAME` → `COMPOSE_PROJECT_NAME` → +`pyproject.toml [project].name` → cwd basename. + +An unknown placeholder raises `NameTemplateError` listing the valid ones. + +```` + +- [ ] **Step 5: README — Reuse mode per-branch note + migration** + +In `README.md` `## Reuse mode`, replace the first bullet under "What changes:" (currently the `-tc--` line, ~line 398-400) with: + +```markdown +- Each container gets a stable, identifiable name from the template + (default `{project}-tc-{service}-{branch}-{dirtag}-{worker}`), so reuse is + scoped per project **and** per git branch **and** per directory. Switching + branch or worktree yields a *new* container; the previous one lingers + (stopped) until you run `pytest --testcontainers-clean`. +``` + +Then add this note at the end of the `## Reuse mode` section (after the concurrent-invocations paragraph, ~line 420): + +```markdown +> **Upgrading from 0.1.0:** reuse-mode names now encode branch + directory, so +> containers reused from an older version are not found by name — a fresh one +> is created and the old one lingers until `pytest --testcontainers-clean`. +``` + +- [ ] **Step 6: SPEC.md — env + CLI tables** + +In `SPEC.md` `### 8.1 Environment variables` table (line 969), add after the `PYTEST_TESTCONTAINERS_PROJECT` row: + +```markdown +| `PROJECT_NAME` | Generic project-name source, below `PYTEST_TESTCONTAINERS_PROJECT` and above `COMPOSE_PROJECT_NAME` (§8.3). | +| `COMPOSE_PROJECT_NAME` | docker-compose's standard project var, below `PROJECT_NAME` (§8.3). | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE` | Override the container-name template (§8.7). | +``` + +In `### 8.2 CLI flags` (line 979), add after the `--testcontainers-project=NAME` row: + +```markdown +| `--testcontainers-name-template=T` | Override the container-name template (§8.7). Highest precedence. | +``` + +- [ ] **Step 7: SPEC.md — amend §8.3 and add §8.7** + +In `SPEC.md` `### 8.3`, replace the resolution list (items 1–4 at lines 996–1001) with: + +```markdown +1. CLI `--testcontainers-project=NAME` if given. +2. Env `PYTEST_TESTCONTAINERS_PROJECT` if set. +3. Env `PROJECT_NAME` if set. +4. Env `COMPOSE_PROJECT_NAME` if set. +5. `pyproject.toml [project].name` from the nearest `pyproject.toml` + walking up from `Path.cwd()` (`tomllib` ≥3.11; `tomli` on 3.10). +6. The cwd directory basename. +7. Fallback literal `"pytest-tc"` (only when the cwd basename is empty, + i.e. the filesystem root) — this is what triggers the one-shot advisory. +``` + +Then insert a new subsection `### 8.7` immediately before `## 9. Error handling` (line 1214): + +```markdown +### 8.7 Container name template + +Every container the plugin starts — in **both** default and reuse mode — is +named by expanding a template and passing the result through `finalize_name` +(§8.4 sanitization + 255-char trim/checksum). + +Default template: `{project}-tc-{service}-{branch}-{dirtag}-{worker}`. + +Placeholders: `{project}` (§8.3), `{service}` (slug), `{branch}` +(`.git/HEAD`, no subprocess; detached → 12-char SHA; no repo → empty), +`{dir}` (worktree-root basename), `{dirtag}` (8-hex blake2s of the +worktree-root absolute path), `{worker}` (xdist id), `{rand}` +(`secrets.token_hex(2)` — empty in reuse mode for byte-stable find-or-create; +in default mode auto-appended when the template omits it). Empty placeholders +collapse with no dangling dashes. An unknown placeholder raises +`NameTemplateError`. + +Template precedence: `--testcontainers-name-template` > +`PYTEST_TESTCONTAINERS_NAME_TEMPLATE` > +`pyproject.toml [tool.pytest_testcontainers].name_template` > built-in default. + +Reuse-mode names are now per-(project, service, branch, directory, worker); +switching branch/worktree creates a new container and leaves the old one until +`--testcontainers-clean`. The maker `reuse_name=` argument still overrides the +whole scheme (sanitized verbatim, not templated). +``` + +- [ ] **Step 8: CLAUDE.md — configuration-precedence table** + +In `CLAUDE.md`, in the "Configuration precedence" table, add rows: + +```markdown +| `PROJECT_NAME=NAME` | — | +| `COMPOSE_PROJECT_NAME=NAME` | — | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=TPL` | `--testcontainers-name-template=TPL` | +``` + +- [ ] **Step 9: Final full-suite + lint gate** + +```bash +uv run pytest tests/ -m "not docker_required" -v +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +``` + +Expected: all green. + +- [ ] **Step 10: Commit** + +```bash +git add README.md SPEC.md CLAUDE.md +git commit -m "Document identifiable container names + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review notes (for the implementer) + +- **Spec coverage:** name in both modes (Task 5); `(project, service, branch, dir, dirtag, worker, rand)` (Tasks 2+4); template + precedence (Tasks 4+6); `PROJECT_NAME`/`COMPOSE_PROJECT_NAME` (Task 3); `finalize_name` 255-cap with full-name checksum (Task 4); `NameTemplateError` (Task 1); conftest env-leak fix (Task 3); reuse migration note (Task 8). All spec sections map to a task. +- **Resolved spec ambiguity (documented decision):** the spec lists "cwd basename (triggers the one-shot advisory)" but also says the advisory fires "only … the literal `pytest-tc` … when there is no cwd basename." This plan implements the latter: cwd basename is returned **silently**; the literal `pytest-tc` + advisory fires only when the basename is empty (filesystem root). This keeps the advisory meaningful (it warns about the generic shared name, not about a perfectly good cwd basename) and is unit-tested via `_project_name_from_dir(Path("/"))`. +- **Type/name consistency:** `container_name_for` (not `reuse_name_for`) is the single naming entry, imported by `makers.py`, `tests/test_reuse.py`, `tests/test_reuse_mode.py`. `compose_name`/`reuse_name_for` are fully removed (Task 4 Step 7 greps to confirm). `finalize_name`, `name_template`, `DEFAULT_NAME_TEMPLATE`, `VALID_PLACEHOLDERS` are the public module names used in tests. From ad61ea7f3a8935272615914622c05693ac99641a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:35:13 +0200 Subject: [PATCH 03/13] Add NameTemplateError exception Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pytest_testcontainers/__init__.py | 2 ++ src/pytest_testcontainers/errors.py | 8 ++++++++ tests/test_errors.py | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/pytest_testcontainers/__init__.py b/src/pytest_testcontainers/__init__.py index 11008cc..30b7f5c 100644 --- a/src/pytest_testcontainers/__init__.py +++ b/src/pytest_testcontainers/__init__.py @@ -7,6 +7,7 @@ CleanSessionFixtureError, ContainerStartError, DockerNotRunningError, + NameTemplateError, PytestTestcontainersError, ReuseConflictError, ) @@ -24,6 +25,7 @@ "ContainerStartError", "DbConnInfo", "DockerNotRunningError", + "NameTemplateError", "PytestTestcontainersError", "ReuseConflictError", "make_container", diff --git a/src/pytest_testcontainers/errors.py b/src/pytest_testcontainers/errors.py index 0f8cc3a..9e81caa 100644 --- a/src/pytest_testcontainers/errors.py +++ b/src/pytest_testcontainers/errors.py @@ -30,6 +30,14 @@ class ReuseConflictError(PytestTestcontainersError): """ +class NameTemplateError(PytestTestcontainersError): + """The configured container-name template referenced an unknown placeholder. + + Raised at name-resolution time (during fixture/maker setup, before any + container starts). The message enumerates the valid placeholders. + """ + + class CleanSessionFixtureError(PytestTestcontainersError): """A clean-session fixture failed to issue an admin command. diff --git a/tests/test_errors.py b/tests/test_errors.py index fc72559..0f456c3 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from pytest_testcontainers.errors import ( CleanSessionFixtureError, ContainerStartError, @@ -44,3 +46,14 @@ def test_clean_session_fixture_error_with_no_exit_code_shows_no_field() -> None: ) rendered = str(exc) assert "exit_code=" not in rendered + + +def test_name_template_error_is_plugin_error() -> None: + from pytest_testcontainers import NameTemplateError + from pytest_testcontainers.errors import NameTemplateError as ErrNameTemplateError + from pytest_testcontainers.errors import PytestTestcontainersError + + assert NameTemplateError is ErrNameTemplateError + assert issubclass(NameTemplateError, PytestTestcontainersError) + with pytest.raises(PytestTestcontainersError): + raise NameTemplateError("bad placeholder") From f332b8ce3e9ed397e4d7bddef3ee171a7ac9ffba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:37:25 +0200 Subject: [PATCH 04/13] Add _internal/git_identity: branch + worktree-root reads Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_internal/git_identity.py | 80 +++++++++++++++++++ tests/test_git_identity.py | 56 +++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/pytest_testcontainers/_internal/git_identity.py create mode 100644 tests/test_git_identity.py diff --git a/src/pytest_testcontainers/_internal/git_identity.py b/src/pytest_testcontainers/_internal/git_identity.py new file mode 100644 index 0000000..711cf8a --- /dev/null +++ b/src/pytest_testcontainers/_internal/git_identity.py @@ -0,0 +1,80 @@ +"""Filesystem-only git identity helpers: worktree root + current branch. + +Pure reads — no subprocess, no docker, no import-time side effects. Used by +``reuse.py`` to compute the ``{branch}``, ``{dir}`` and ``{dirtag}`` name +components. Handles both a normal clone (``.git`` is a directory) and a +linked worktree (``.git`` is a file containing ``gitdir: ``). +""" + +from __future__ import annotations + +from pathlib import Path + + +def worktree_root(start: Path) -> Path: + """Return the directory that owns the working tree containing ``start``. + + Walks upward from ``start`` looking for a ``.git`` entry (a directory in a + normal clone, a file in a linked worktree — either marks the root). Falls + back to ``start.resolve()`` when no ``.git`` is found. + """ + current = start.resolve() + for candidate in [current, *current.parents]: + if (candidate / ".git").exists(): + return candidate + return current + + +def _gitdir_for(root: Path) -> Path | None: + """Resolve the gitdir that holds ``HEAD`` for the worktree at ``root``. + + Normal clone: ``/.git`` is a directory → that directory. + Linked worktree: ``/.git`` is a file ``gitdir: `` → ```` + (resolved relative to ``root`` when the recorded path is relative). + Returns ``None`` when neither shape is readable. + """ + dot_git = root / ".git" + if dot_git.is_dir(): + return dot_git + if dot_git.is_file(): + try: + content = dot_git.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + prefix = "gitdir:" + if content.startswith(prefix): + target = content[len(prefix) :].strip() + if target: + resolved = Path(target) + if not resolved.is_absolute(): + resolved = (root / resolved).resolve() + return resolved + return None + + +def current_branch(start: Path) -> str | None: + """Return the current git branch name, or ``None``. + + Reads ``HEAD`` from the resolved gitdir without spawning git: + - ``ref: refs/heads/`` → ```` + - any other ``ref: .../`` → last path component ```` + - raw SHA (detached HEAD) → first 12 chars + - unreadable / missing / empty → ``None`` + """ + root = worktree_root(start) + gitdir = _gitdir_for(root) + if gitdir is None: + return None + try: + content = (gitdir / "HEAD").read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + if not content: + return None + if content.startswith("ref:"): + ref = content[len("ref:") :].strip() + marker = "refs/heads/" + if marker in ref: + return ref.split(marker, 1)[1] or None + return ref.rsplit("/", 1)[-1] or None + return content[:12] diff --git a/tests/test_git_identity.py b/tests/test_git_identity.py new file mode 100644 index 0000000..672e919 --- /dev/null +++ b/tests/test_git_identity.py @@ -0,0 +1,56 @@ +"""Filesystem-only git identity: worktree root + current branch (no subprocess).""" + +from __future__ import annotations + +from pathlib import Path + +from pytest_testcontainers._internal.git_identity import current_branch, worktree_root + + +def _make_repo(root: Path, head: str) -> None: + """Create a normal-clone .git dir with a HEAD file at ``root``.""" + git = root / ".git" + git.mkdir(parents=True) + (git / "HEAD").write_text(head, encoding="utf-8") + + +def test_worktree_root_is_dir_holding_dot_git(tmp_path: Path) -> None: + _make_repo(tmp_path, "ref: refs/heads/main\n") + sub = tmp_path / "pkg" / "deep" + sub.mkdir(parents=True) + assert worktree_root(sub) == tmp_path.resolve() + + +def test_worktree_root_no_repo_falls_back_to_start(tmp_path: Path) -> None: + sub = tmp_path / "nope" + sub.mkdir() + assert worktree_root(sub) == sub.resolve() + + +def test_current_branch_attached_ref(tmp_path: Path) -> None: + _make_repo(tmp_path, "ref: refs/heads/feature/x\n") + assert current_branch(tmp_path) == "feature/x" + + +def test_current_branch_detached_head_short_sha(tmp_path: Path) -> None: + sha = "0123456789abcdef0123456789abcdef01234567" + _make_repo(tmp_path, sha + "\n") + assert current_branch(tmp_path) == sha[:12] + + +def test_current_branch_no_repo_is_none(tmp_path: Path) -> None: + assert current_branch(tmp_path) is None + + +def test_current_branch_linked_worktree_gitfile(tmp_path: Path) -> None: + # Real gitdir lives elsewhere; the worktree's .git is a file pointing to it. + real_gitdir = tmp_path / "realgit" + real_gitdir.mkdir() + (real_gitdir / "HEAD").write_text("ref: refs/heads/wt-branch\n", encoding="utf-8") + + wt = tmp_path / "wt" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {real_gitdir.resolve()}\n", encoding="utf-8") + + assert worktree_root(wt) == wt.resolve() + assert current_branch(wt) == "wt-branch" From d12530d89b9f8577683daa60dc719a17cf5dbff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:40:45 +0200 Subject: [PATCH 05/13] Extend project-name precedence: PROJECT_NAME, COMPOSE_PROJECT_NAME, cwd basename Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pytest_testcontainers/reuse.py | 48 ++++++++++++++----- tests/conftest.py | 3 ++ tests/test_reuse.py | 75 ++++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/pytest_testcontainers/reuse.py b/src/pytest_testcontainers/reuse.py index b0b34ff..531def4 100644 --- a/src/pytest_testcontainers/reuse.py +++ b/src/pytest_testcontainers/reuse.py @@ -102,8 +102,12 @@ def worker_id() -> str: # -------------------------------------------------------------------------- # Project name resolution # -------------------------------------------------------------------------- -def _read_pyproject_project_name(start: Path) -> str | None: - """Walk upward from ``start`` looking for a ``pyproject.toml`` with [project].name.""" +def _find_pyproject_data(start: Path) -> dict | None: + """Return parsed data of the nearest ``pyproject.toml`` walking up from ``start``. + + The first ``pyproject.toml`` found wins (even if it lacks the field a + caller wants). Returns ``None`` when none is reachable or parsing fails. + """ try: if sys.version_info >= (3, 11): import tomllib # type: ignore[import-not-found] @@ -119,14 +123,21 @@ def _read_pyproject_project_name(start: Path) -> str | None: continue try: with pyproject.open("rb") as fh: - data = tomllib.load(fh) + return tomllib.load(fh) except (OSError, ValueError): return None - name = data.get("project", {}).get("name") - if isinstance(name, str) and name.strip(): - return name.strip() - # First pyproject.toml found wins, even if it lacks [project].name + return None + + +def _read_pyproject_project_name(start: Path) -> str | None: + """Read ``[project].name`` from the nearest ``pyproject.toml``.""" + data = _find_pyproject_data(start) + if data is None: return None + project = data.get("project", {}) + name = project.get("name") if isinstance(project, dict) else None + if isinstance(name, str) and name.strip(): + return name.strip() return None @@ -146,20 +157,33 @@ def _emit_fallback_warning() -> None: def project_name() -> str: - """Resolve the ```` component used in reuse names. + """Resolve the ``{project}`` component used in container names. - Precedence (CLI > env > pyproject > literal fallback) is enforced - here. Triggers the one-shot stderr fallback warning if and only if - we land on the literal fallback. + Precedence (most explicit first): CLI override, the plugin's own + ``PYTEST_TESTCONTAINERS_PROJECT``, the generic ``PROJECT_NAME`` then + ``COMPOSE_PROJECT_NAME`` env vars, ``pyproject.toml [project].name``, the + cwd basename, and finally the literal ``"pytest-tc"`` (which triggers the + one-shot stderr advisory). """ if _cli_project: return _cli_project env = os.environ.get("PYTEST_TESTCONTAINERS_PROJECT") if env: return env - found = _read_pyproject_project_name(Path.cwd()) + generic = os.environ.get("PROJECT_NAME") or os.environ.get("COMPOSE_PROJECT_NAME") + if generic: + return generic + return _project_name_from_dir(Path.cwd()) + + +def _project_name_from_dir(cwd: Path) -> str: + """File/basename/literal tail of :func:`project_name` (separately testable).""" + found = _read_pyproject_project_name(cwd) if found: return found + basename = cwd.name + if basename: + return basename _emit_fallback_warning() return "pytest-tc" diff --git a/tests/conftest.py b/tests/conftest.py index 6caaf12..13fe2cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,9 @@ def _clean_state(monkeypatch: pytest.MonkeyPatch) -> None: "PYTEST_TESTCONTAINERS_PROJECT", "PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK", "PYTEST_TESTCONTAINERS_QUIET", + "PYTEST_TESTCONTAINERS_NAME_TEMPLATE", + "PROJECT_NAME", + "COMPOSE_PROJECT_NAME", ): monkeypatch.delenv(var, raising=False) yield diff --git a/tests/test_reuse.py b/tests/test_reuse.py index f7b421f..ad439ed 100644 --- a/tests/test_reuse.py +++ b/tests/test_reuse.py @@ -3,6 +3,7 @@ from __future__ import annotations import textwrap +from pathlib import Path import pytest @@ -102,25 +103,71 @@ def test_project_name_walks_up_to_parent(tmp_cwd, monkeypatch: pytest.MonkeyPatc assert project_name() == "outer-project" -def test_project_name_fallback_emits_warning(tmp_cwd, capsys: pytest.CaptureFixture[str]) -> None: - # tmp_cwd is empty — no pyproject.toml; CLI/env not set - assert project_name() == "pytest-tc" - captured = capsys.readouterr() - assert "fallback project name" in captured.err - # one-shot: second call does not warn - capsys.readouterr() - assert project_name() == "pytest-tc" - captured = capsys.readouterr() - assert captured.err == "" +def test_project_name_PROJECT_NAME_env(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("PROJECT_NAME", "from-project-name") + assert project_name() == "from-project-name" -def test_project_name_fallback_warning_suppressed_by_quiet( +def test_project_name_COMPOSE_PROJECT_NAME_env(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "from-compose") + assert project_name() == "from-compose" + + +def test_project_name_PROJECT_NAME_beats_compose(monkeypatch: pytest.MonkeyPatch, tmp_cwd) -> None: + monkeypatch.setenv("PROJECT_NAME", "wins") + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "loses") + assert project_name() == "wins" + + +def test_project_name_plugin_env_beats_PROJECT_NAME( + monkeypatch: pytest.MonkeyPatch, tmp_cwd +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "plugin-env") + monkeypatch.setenv("PROJECT_NAME", "generic-env") + assert project_name() == "plugin-env" + + +def test_project_name_PROJECT_NAME_beats_pyproject( + monkeypatch: pytest.MonkeyPatch, tmp_cwd +) -> None: + (tmp_cwd / "pyproject.toml").write_text( + textwrap.dedent("""\ + [project] + name = "from-pyproject" + """) + ) + monkeypatch.setenv("PROJECT_NAME", "from-project-name") + assert project_name() == "from-project-name" + + +def test_project_name_cwd_basename_fallback( tmp_cwd, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # No pyproject, no env: fall back to the cwd directory basename, no warning. + app = tmp_cwd / "myapp" + app.mkdir() + monkeypatch.chdir(app) + assert project_name() == "myapp" + assert capsys.readouterr().err == "" + + +def test_project_name_literal_fallback_emits_warning( + capsys: pytest.CaptureFixture[str], +) -> None: + # Filesystem root has an empty basename → literal fallback + one-shot warning. + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert "fallback project name" in capsys.readouterr().err + # one-shot: second call is silent + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert capsys.readouterr().err == "" + + +def test_project_name_literal_fallback_suppressed_by_quiet( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("PYTEST_TESTCONTAINERS_QUIET", "1") - assert project_name() == "pytest-tc" - captured = capsys.readouterr() - assert captured.err == "" + assert reuse._project_name_from_dir(Path("/")) == "pytest-tc" + assert capsys.readouterr().err == "" def test_reuse_enabled_cli_wins(monkeypatch: pytest.MonkeyPatch) -> None: From 53cd453a4f317d70b239bda4cbd5b36d3c426537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:48:01 +0200 Subject: [PATCH 06/13] Add template-based container naming: render, finalize_name, container_name_for Rewire makers to container_name_for so containers are named in both default and reuse mode (folds the makers import/call update forward to keep the package importable after reuse_name_for is removed). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pytest_testcontainers/makers.py | 9 +- src/pytest_testcontainers/reuse.py | 117 +++++++++++++++++---- tests/test_reuse.py | 155 ++++++++++++++++++++++------ tests/test_reuse_mode.py | 4 +- 4 files changed, 229 insertions(+), 56 deletions(-) diff --git a/src/pytest_testcontainers/makers.py b/src/pytest_testcontainers/makers.py index 4d1be7c..5e502d3 100644 --- a/src/pytest_testcontainers/makers.py +++ b/src/pytest_testcontainers/makers.py @@ -27,8 +27,8 @@ start_or_raise, ) from pytest_testcontainers.reuse import ( + container_name_for, is_reuse_enabled, - reuse_name_for, sanitize_component, ) @@ -76,7 +76,7 @@ def _make_generic( check_docker_daemon() reuse_on = is_reuse_enabled() or reuse_name is not None - name = reuse_name_for(service_slug, override=reuse_name) if reuse_on else None + name = container_name_for(service_slug, override=reuse_name, reuse_mode=reuse_on) if reuse_on: disable_ryuk_once() @@ -84,7 +84,7 @@ def _make_generic( instance: DockerContainer | None = None is_bound_to_existing = False - if reuse_on and name is not None: + if reuse_on: existing = find_existing_container(name) if existing is not None: status = getattr(existing, "status", "") @@ -106,8 +106,7 @@ def _make_generic( if instance is None: instance = container_cls(*constructor_args, **constructor_kwargs) - if name is not None: - instance.with_name(name) + instance.with_name(name) _apply_env(instance, env) image_for_msg = constructor_kwargs.get("image") or ( diff --git a/src/pytest_testcontainers/reuse.py b/src/pytest_testcontainers/reuse.py index 531def4..8ac8cfd 100644 --- a/src/pytest_testcontainers/reuse.py +++ b/src/pytest_testcontainers/reuse.py @@ -11,14 +11,19 @@ import hashlib import os import re +import secrets import sys import threading from pathlib import Path +from pytest_testcontainers._internal import git_identity +from pytest_testcontainers.errors import NameTemplateError + # CLI overrides arrive at parse time and live process-wide. _cli_state_lock = threading.Lock() _cli_project: str | None = None _cli_reuse: bool | None = None # tri-state: None=unset, True=force, False=force off +_cli_name_template: str | None = None # One-shot fallback warning gate (see §8.3 / §8.1). _fallback_warned: bool = False @@ -39,14 +44,21 @@ def set_cli_reuse(value: bool | None) -> None: _cli_reuse = value +def set_cli_name_template(value: str | None) -> None: + global _cli_name_template + with _cli_state_lock: + _cli_name_template = value + + def reset_cli_state() -> None: """Used in tests to keep cross-test state from leaking.""" - global _cli_project, _cli_reuse, _cli_disabled, _fallback_warned + global _cli_project, _cli_reuse, _cli_disabled, _fallback_warned, _cli_name_template with _cli_state_lock: _cli_project = None _cli_reuse = None _cli_disabled = None _fallback_warned = False + _cli_name_template = None # -------------------------------------------------------------------------- @@ -206,28 +218,95 @@ def sanitize_component(component: str) -> str: return s or "x" -def compose_name(project: str, service: str, worker: str) -> str: - """Compose the full ``-tc--`` reuse name. +DEFAULT_NAME_TEMPLATE = "{project}-tc-{service}-{branch}-{dirtag}-{worker}" +VALID_PLACEHOLDERS = frozenset({"project", "service", "branch", "dir", "dirtag", "worker", "rand"}) +_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}") - Truncates to 255 chars and appends a 4-char hash if the natural - composition would overflow Docker's container-name limit. - """ - p = sanitize_component(project) - s = sanitize_component(service) - w = sanitize_component(worker) - name = f"{p}-tc-{s}-{w}" - if len(name) <= 255: - return name - h = hashlib.blake2s(name.encode(), digest_size=2).hexdigest() - return name[:250] + "-" + h +def _read_pyproject_name_template(start: Path) -> str | None: + """Read ``[tool.pytest_testcontainers].name_template`` from pyproject.""" + data = _find_pyproject_data(start) + if data is None: + return None + tool = data.get("tool", {}) + table = tool.get("pytest_testcontainers", {}) if isinstance(tool, dict) else {} + template = table.get("name_template") if isinstance(table, dict) else None + if isinstance(template, str) and template.strip(): + return template.strip() + return None -def reuse_name_for(service: str, *, override: str | None = None) -> str: - """Compute the reuse name for ``service`` (already a short slug). - If ``override`` is given (the user passed ``reuse_name=`` to a maker), - it takes precedence verbatim — sanitized, but not composed. +def name_template() -> str: + """Resolve the container-name template (CLI > env > pyproject > default).""" + if _cli_name_template: + return _cli_name_template + env = os.environ.get("PYTEST_TESTCONTAINERS_NAME_TEMPLATE") + if env: + return env + found = _read_pyproject_name_template(Path.cwd()) + if found: + return found + return DEFAULT_NAME_TEMPLATE + + +def _render_template(template: str, values: dict[str, str]) -> str: + """Substitute ``{placeholder}`` tokens; unknown placeholder → error.""" + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + if key not in values: + valid = ", ".join("{" + p + "}" for p in sorted(VALID_PLACEHOLDERS)) + raise NameTemplateError( + f"unknown placeholder {{{key}}} in container name template; " + f"valid placeholders are: {valid}" + ) + return values[key] + + return _PLACEHOLDER_RE.sub(_replace, template) + + +def _build_values(service: str, *, rand: str) -> dict[str, str]: + """Resolve every placeholder value for the current cwd / git state.""" + cwd = Path.cwd() + root = git_identity.worktree_root(cwd) + dirtag = hashlib.blake2s(str(root).encode(), digest_size=4).hexdigest() + return { + "project": project_name(), + "service": service, + "branch": git_identity.current_branch(cwd) or "", + "dir": root.name, + "dirtag": dirtag, + "worker": worker_id(), + "rand": rand, + } + + +def finalize_name(name: str) -> str: + """Make ``name`` Docker-safe and ≤255 chars (deterministic trim+checksum).""" + sanitized = sanitize_component(name) + if len(sanitized) <= 255: + return sanitized + digest = hashlib.blake2s(name.encode(), digest_size=4).hexdigest() + return sanitized[:246] + "-" + digest + + +def container_name_for( + service: str, *, override: str | None = None, reuse_mode: bool = True +) -> str: + """Compute the container name for ``service``. + + ``override`` (the maker ``reuse_name=`` argument) wins verbatim, sanitized + but not templated. Otherwise the configured template is rendered and + finalized. In reuse mode ``{rand}`` is empty so the name is byte-stable for + find-or-create; in default mode a fresh ``secrets.token_hex(2)`` is used + (auto-appended when the template does not already contain ``{rand}``) so + overlapping or leaked runs never collide. """ if override is not None: return sanitize_component(override) - return compose_name(project_name(), service, worker_id()) + template = name_template() + rand = "" if reuse_mode else secrets.token_hex(2) + rendered = _render_template(template, _build_values(service, rand=rand)) + if not reuse_mode and "{rand}" not in template: + rendered = f"{rendered}-{rand}" + return finalize_name(rendered) diff --git a/tests/test_reuse.py b/tests/test_reuse.py index ad439ed..636cf66 100644 --- a/tests/test_reuse.py +++ b/tests/test_reuse.py @@ -2,18 +2,22 @@ from __future__ import annotations +import re import textwrap from pathlib import Path import pytest from pytest_testcontainers import reuse +from pytest_testcontainers.errors import NameTemplateError from pytest_testcontainers.reuse import ( - compose_name, + DEFAULT_NAME_TEMPLATE, + container_name_for, + finalize_name, is_plugin_disabled, is_reuse_enabled, + name_template, project_name, - reuse_name_for, sanitize_component, worker_id, ) @@ -38,21 +42,6 @@ def test_sanitize_component(raw: str, expected: str) -> None: assert sanitize_component(raw) == expected -def test_compose_name_short() -> None: - assert compose_name("myproject", "psql", "master") == "myproject-tc-psql-master" - assert compose_name("myproject", "redis", "gw0") == "myproject-tc-redis-gw0" - - -def test_compose_name_truncates_when_long() -> None: - long_project = "x" * 300 - out = compose_name(long_project, "psql", "master") - assert len(out) <= 255 - assert out.startswith("x") - assert "-tc-" not in out[-30:] or out.endswith( - ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f") - ) - - def test_worker_id_default(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) assert worker_id() == "master" @@ -199,28 +188,134 @@ def test_plugin_enabled_by_default() -> None: assert is_plugin_disabled() is False -def test_reuse_name_for_uses_override(tmp_cwd) -> None: - name = reuse_name_for("psql", override="MY_NAME") - assert name == "my_name" +# --- template rendering ------------------------------------------------- + + +def test_render_expands_all_placeholders() -> None: + values = { + "project": "myproj", + "service": "psql", + "branch": "feature-x", + "dir": "checkout", + "dirtag": "3f9ac1b2", + "worker": "gw0", + "rand": "ab12", + } + out = reuse._render_template( + "{project}-tc-{service}-{branch}-{dir}-{dirtag}-{worker}-{rand}", values + ) + assert out == "myproj-tc-psql-feature-x-checkout-3f9ac1b2-gw0-ab12" + + +def test_render_unknown_placeholder_raises() -> None: + with pytest.raises(NameTemplateError) as exc: + reuse._render_template("{project}-{nope}", {"project": "p"}) + assert "nope" in str(exc.value) + assert "project" in str(exc.value) # message lists valid placeholders + + +def test_finalize_collapses_empty_branch() -> None: + # An empty {branch} leaves a double dash that finalize collapses cleanly. + rendered = reuse._render_template( + DEFAULT_NAME_TEMPLATE, + { + "project": "myproj", + "service": "psql", + "branch": "", + "dir": "d", + "dirtag": "3f9ac1b2", + "worker": "master", + "rand": "", + }, + ) + assert finalize_name(rendered) == "myproj-tc-psql-3f9ac1b2-master" + + +# --- finalize_name length safety --------------------------------------- + + +def test_finalize_name_short_passthrough() -> None: + assert finalize_name("MyProj-TC-Psql-Master") == "myproj-tc-psql-master" + + +def test_finalize_name_caps_at_255() -> None: + out = finalize_name("x" * 400) + assert len(out) <= 255 + + +def test_finalize_name_deterministic() -> None: + long = "y" * 400 + assert finalize_name(long) == finalize_name(long) + + +def test_finalize_name_distinguishes_tail() -> None: + base = "z" * 300 + a = finalize_name(base + "-alpha") + b = finalize_name(base + "-omega") + assert a != b # hash over the full name, not just the kept prefix + +# --- name_template precedence ------------------------------------------ -def test_reuse_name_for_composes(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + +def test_name_template_default(tmp_cwd) -> None: + assert name_template() == DEFAULT_NAME_TEMPLATE + + +def test_name_template_env_over_pyproject(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_cwd / "pyproject.toml").write_text( textwrap.dedent("""\ - [project] - name = "MyProj" + [tool.pytest_testcontainers] + name_template = "{service}-from-pyproject" """) ) - monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) - assert reuse_name_for("psql") == "myproj-tc-psql-master" + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-from-env") + assert name_template() == "{service}-from-env" -def test_reuse_name_for_with_xdist_worker(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: +def test_name_template_pyproject(tmp_cwd) -> None: (tmp_cwd / "pyproject.toml").write_text( textwrap.dedent("""\ - [project] - name = "MyProj" + [tool.pytest_testcontainers] + name_template = "{service}-from-pyproject" """) ) - monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw7") - assert reuse_name_for("redis") == "myproj-tc-redis-gw7" + assert name_template() == "{service}-from-pyproject" + + +def test_name_template_cli_wins(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-env") + reuse.set_cli_name_template("{service}-cli") + assert name_template() == "{service}-cli" + + +# --- container_name_for end to end ------------------------------------- + + +def test_container_name_for_override(tmp_cwd) -> None: + assert container_name_for("psql", override="MY_NAME") == "my_name" + + +def test_container_name_for_reuse_is_byte_stable(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{worker}") + monkeypatch.setenv("PROJECT_NAME", "myproj") + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + first = container_name_for("psql", reuse_mode=True) + second = container_name_for("psql", reuse_mode=True) + assert first == second == "myproj-tc-psql-master" + + +def test_container_name_for_default_appends_rand(tmp_cwd, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{worker}") + monkeypatch.setenv("PROJECT_NAME", "myproj") + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + name = container_name_for("psql", reuse_mode=False) + assert re.match(r"^myproj-tc-psql-master-[0-9a-f]{4}$", name) + + +def test_container_name_for_explicit_rand_not_double_appended( + tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{service}-{rand}") + name = container_name_for("psql", reuse_mode=False) + assert re.match(r"^psql-[0-9a-f]{4}$", name) # exactly one rand token diff --git a/tests/test_reuse_mode.py b/tests/test_reuse_mode.py index 86c6b13..e8c25e1 100644 --- a/tests/test_reuse_mode.py +++ b/tests/test_reuse_mode.py @@ -10,7 +10,7 @@ import pytest from pytest_testcontainers import make_redis -from pytest_testcontainers.reuse import reuse_name_for +from pytest_testcontainers.reuse import container_name_for pytestmark = pytest.mark.docker_required @@ -30,7 +30,7 @@ def _remove_named(name: str) -> None: def test_reuse_keeps_same_container_id(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PYTEST_TESTCONTAINERS_REUSE", "1") monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "ptc-smoke") - name = reuse_name_for("redis") + name = container_name_for("redis") _remove_named(name) try: with make_redis() as r1: From 471989f3a1c1052ebb6585ca3438ebcebe84e608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:52:04 +0200 Subject: [PATCH 07/13] Add no-docker tests: containers named in both default and reuse mode Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_makers_naming.py | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_makers_naming.py diff --git a/tests/test_makers_naming.py b/tests/test_makers_naming.py new file mode 100644 index 0000000..7fedb30 --- /dev/null +++ b/tests/test_makers_naming.py @@ -0,0 +1,71 @@ +"""Maker naming with the daemon ping and start mocked out (no real Docker).""" + +from __future__ import annotations + +import re + +import pytest + +from pytest_testcontainers import makers +from pytest_testcontainers.makers import make_container +from pytest_testcontainers.reuse import container_name_for + + +class _Fake: + def __init__(self, *args, **kwargs) -> None: + self.assigned_name: str | None = None + + def with_name(self, name): + self.assigned_name = name + return self + + def with_env(self, *args, **kwargs): + return self + + def stop(self): + pass + + +@pytest.fixture +def _stub_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(makers, "check_docker_daemon", lambda: None) + monkeypatch.setattr(makers, "start_or_raise", lambda instance, image: None) + + +def test_default_mode_assigns_templated_name( + _stub_runtime, tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + captured: dict[str, str] = {} + + class Fake(_Fake): + def with_name(self, name): + captured["name"] = name + return super().with_name(name) + + with make_container(Fake): + pass + + name = captured["name"] + assert "-tc-fake-" in name # service slug derived from the class name + assert re.search(r"-[0-9a-f]{4}$", name) # default mode appends a rand token + + +def test_reuse_mode_assigns_deterministic_name( + _stub_runtime, tmp_cwd, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_REUSE", "1") + monkeypatch.setattr(makers, "find_existing_container", lambda name: None) + monkeypatch.setattr(makers, "disable_ryuk_once", lambda: None) + captured: dict[str, str] = {} + + class Fake(_Fake): + def with_name(self, name): + captured["name"] = name + return super().with_name(name) + + expected = container_name_for("fake", reuse_mode=True) + with make_container(Fake): + pass + + assert captured["name"] == expected From 8ca52999a7a2fde95c156e9c6cdddefbee7c21cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:55:55 +0200 Subject: [PATCH 08/13] Add --testcontainers-name-template CLI flag Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pytest_testcontainers/plugin.py | 13 ++++++++++ tests/test_plugin_options.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/test_plugin_options.py diff --git a/src/pytest_testcontainers/plugin.py b/src/pytest_testcontainers/plugin.py index f40cfe3..5bb9c6f 100644 --- a/src/pytest_testcontainers/plugin.py +++ b/src/pytest_testcontainers/plugin.py @@ -50,6 +50,16 @@ def pytest_addoption(parser: pytest.Parser) -> None: dest="testcontainers_project", help="Override project name used in reuse-name prefix.", ) + group.addoption( + "--testcontainers-name-template", + action="store", + default=None, + dest="testcontainers_name_template", + help=( + "Template for container names. Placeholders: {project} {service} " + "{branch} {dir} {dirtag} {worker} {rand}." + ), + ) group.addoption( "--testcontainers-clean", action="store_true", @@ -65,6 +75,9 @@ def pytest_configure(config: pytest.Config) -> None: project = config.getoption("testcontainers_project") if project: reuse.set_cli_project(project) + name_template = config.getoption("testcontainers_name_template") + if name_template: + reuse.set_cli_name_template(name_template) if config.getoption("testcontainers_no_reuse"): reuse.set_cli_reuse(False) elif config.getoption("testcontainers_reuse"): diff --git a/tests/test_plugin_options.py b/tests/test_plugin_options.py new file mode 100644 index 0000000..093606d --- /dev/null +++ b/tests/test_plugin_options.py @@ -0,0 +1,39 @@ +"""Plugin CLI option wiring (no pytester — exercise the hooks directly).""" + +from __future__ import annotations + +from pytest_testcontainers import plugin, reuse + + +class _StubConfig: + """Minimal duck-typed pytest.Config exposing getoption().""" + + def __init__(self, options: dict[str, object]) -> None: + self._options = options + + def getoption(self, name: str): + return self._options.get(name) + + +def _base_options(**overrides: object) -> dict[str, object]: + opts: dict[str, object] = { + "testcontainers_disabled": False, + "testcontainers_project": None, + "testcontainers_no_reuse": False, + "testcontainers_reuse": False, + "testcontainers_name_template": None, + } + opts.update(overrides) + return opts + + +def test_name_template_flag_wires_into_reuse_state() -> None: + config = _StubConfig(_base_options(testcontainers_name_template="{service}-from-cli")) + plugin.pytest_configure(config) + assert reuse.name_template() == "{service}-from-cli" + + +def test_no_name_template_flag_leaves_default(tmp_cwd) -> None: + config = _StubConfig(_base_options()) + plugin.pytest_configure(config) + assert reuse.name_template() == reuse.DEFAULT_NAME_TEMPLATE From d43f9537c5e36024c1617833a329a7f517a8833e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 21:58:11 +0200 Subject: [PATCH 09/13] Add docker_required naming smoke tests Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_naming_docker.py | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_naming_docker.py diff --git a/tests/test_naming_docker.py b/tests/test_naming_docker.py new file mode 100644 index 0000000..8147553 --- /dev/null +++ b/tests/test_naming_docker.py @@ -0,0 +1,59 @@ +"""docker_required: a default-mode container really carries the templated name, +and reuse identity differs across git branches.""" + +from __future__ import annotations + +import pytest + +from pytest_testcontainers import make_redis +from pytest_testcontainers.reuse import container_name_for + +pytestmark = pytest.mark.docker_required + + +def _remove_named(name: str) -> None: + import docker + from docker.errors import NotFound + + client = docker.from_env() + try: + client.containers.get(name).remove(force=True) + except NotFound: + return + + +def test_default_mode_container_has_templated_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "ptc-name-smoke") + with make_redis() as r: + docker_name = r._container.name + assert docker_name.startswith("ptc-name-smoke-tc-redis-") + + +def test_reuse_identity_differs_across_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The {branch} component is part of the reuse identity, so two branches + # resolve to two distinct container names. + monkeypatch.setenv("PYTEST_TESTCONTAINERS_PROJECT", "ptc-branch-smoke") + monkeypatch.setenv("PYTEST_TESTCONTAINERS_NAME_TEMPLATE", "{project}-tc-{service}-{branch}") + monkeypatch.setenv("PYTEST_TESTCONTAINERS_REUSE", "1") + + monkeypatch.setattr( + "pytest_testcontainers.reuse.git_identity.current_branch", + lambda start: "branch-a", + ) + name_a = container_name_for("redis") + + monkeypatch.setattr( + "pytest_testcontainers.reuse.git_identity.current_branch", + lambda start: "branch-b", + ) + name_b = container_name_for("redis") + + assert name_a != name_b + assert name_a == "ptc-branch-smoke-tc-redis-branch-a" + assert name_b == "ptc-branch-smoke-tc-redis-branch-b" + + _remove_named("ptc-name-smoke-tc-redis") # defensive: no leak from sibling test From 7b75c0b9dbb1099be22a3eed9dd6d4839170e9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 22:03:44 +0200 Subject: [PATCH 10/13] Document identifiable container names Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +++ README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- SPEC.md | 41 ++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db3e979..cda2a69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,9 @@ CLI flag > env var > default. Implemented in `plugin.py` and read once into modu | `PYTEST_TESTCONTAINERS=0` | `--no-testcontainers` | | `PYTEST_TESTCONTAINERS_REUSE=1` | `--testcontainers-reuse` / `--testcontainers-no-reuse` | | `PYTEST_TESTCONTAINERS_PROJECT=NAME` | `--testcontainers-project=NAME` | +| `PROJECT_NAME=NAME` | — | +| `COMPOSE_PROJECT_NAME=NAME` | — | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=TPL` | `--testcontainers-name-template=TPL` | | `PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK=1` | — | | `PYTEST_TESTCONTAINERS_QUIET=1` | — | | — | `--testcontainers-clean` (prune + exit 0) | diff --git a/README.md b/README.md index aa0c327..b583742 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,53 @@ Every plumbing concern — daemon ping, reuse name, atexit cleanup, Ryuk-disable-when-reuse — applies. `args`/`kwargs` go to the upstream constructor verbatim. +## Container names + +Every container the plugin starts is named so you can find it in `docker ps` +(previously default-mode containers got random Docker names like +`heuristic_cannon`). The name is built from a template: + +``` +{project}-tc-{service}-{branch}-{dirtag}-{worker} +``` + +e.g. `myproj-tc-psql-feature-x-3f9ac1b2-master`. + +Placeholders: + +| Placeholder | Meaning | +|-------------|---------| +| `{project}` | Project name (see precedence below). | +| `{service}` | Service slug (`psql`, `redis`, `mysql`, `mongo`, `rabbitmq`, or derived from the container class). | +| `{branch}` | Current git branch (read from `.git/HEAD`, no subprocess). Detached HEAD → 12-char commit SHA. No repo → empty. | +| `{dir}` | Worktree-root basename. Readable, not collision-proof. | +| `{dirtag}` | 8-hex hash of the worktree-root absolute path. Disambiguates the same branch checked out in two directories. | +| `{worker}` | xdist worker id (`master` when not under xdist). | +| `{rand}` | 4 random hex chars. Empty in reuse mode; auto-appended in default mode when the template omits it. | + +Empty placeholders collapse cleanly (no `--`, no dangling dashes). Names are +sanitized to Docker's charset and capped at 255 chars (trim + checksum). + +Override the template (CLI > env > pyproject > built-in default): + +```bash +pytest --testcontainers-name-template='{project}-{service}-{branch}' +# or +export PYTEST_TESTCONTAINERS_NAME_TEMPLATE='{project}-{service}-{branch}' +``` + +```toml +# pyproject.toml +[tool.pytest_testcontainers] +name_template = "{project}-{service}-{branch}" +``` + +The `{project}` component resolves as: `--testcontainers-project` → +`PYTEST_TESTCONTAINERS_PROJECT` → `PROJECT_NAME` → `COMPOSE_PROJECT_NAME` → +`pyproject.toml [project].name` → cwd basename. + +An unknown placeholder raises `NameTemplateError` listing the valid ones. + ## Reuse mode For iterative dev loops where you don't want to pay container-start @@ -395,9 +442,11 @@ pytest --testcontainers-reuse tests/ ``` What changes: -- Each container gets a stable name `-tc--` - (e.g. `myproject-tc-psql-master`). The project name comes from - `pyproject.toml [project].name`. +- Each container gets a stable, identifiable name from the template + (default `{project}-tc-{service}-{branch}-{dirtag}-{worker}`), so reuse is + scoped per project **and** per git branch **and** per directory. Switching + branch or worktree yields a *new* container; the previous one lingers + (stopped) until you run `pytest --testcontainers-clean`. - Ryuk (testcontainers' reaper) is disabled so the named containers survive between runs. - On the next run we look up by name — found-and-running gets bound @@ -419,6 +468,10 @@ own `PYTEST_TESTCONTAINERS_PROJECT` to avoid name collisions. The plugin doesn't auto-namespace by PID — that would defeat the "reuse across runs" point. +> **Upgrading from 0.1.0:** reuse-mode names now encode branch + directory, so +> containers reused from an older version are not found by name — a fresh one +> is created and the old one lingers until `pytest --testcontainers-clean`. + ## Configuration No TOML config table. The handful of toggles read at maker-call time: @@ -430,6 +483,9 @@ No TOML config table. The handful of toggles read at maker-call time: | `PYTEST_TESTCONTAINERS=0` | Disable plugin fixtures (raise UsageError). | | `PYTEST_TESTCONTAINERS_REUSE=1` | Reuse named containers across runs. | | `PYTEST_TESTCONTAINERS_PROJECT=` | Override the `` part of reuse names. | +| `PROJECT_NAME=` | Generic project-name source (below the plugin var). | +| `COMPOSE_PROJECT_NAME=` | docker-compose's project var (below `PROJECT_NAME`). | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=` | Override the container-name template. | | `PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK=1` | Skip Docker daemon ping (rare). | | `PYTEST_TESTCONTAINERS_QUIET=1` | Suppress one-shot informational advisories. | @@ -441,6 +497,7 @@ No TOML config table. The handful of toggles read at maker-call time: | `--testcontainers-reuse` | `PYTEST_TESTCONTAINERS_REUSE=1` | | `--testcontainers-no-reuse` | force fresh-each-run mode | | `--testcontainers-project=NAME` | `PYTEST_TESTCONTAINERS_PROJECT=NAME` | +| `--testcontainers-name-template=T` | `PYTEST_TESTCONTAINERS_NAME_TEMPLATE=T` | | `--testcontainers-clean` | prune `-tc-*` and exit 0 | CLI > env > defaults. diff --git a/SPEC.md b/SPEC.md index 01387b9..2357ad4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -973,6 +973,9 @@ No TOML config table. The handful of toggles read at maker-call time: | `PYTEST_TESTCONTAINERS=0` | Disable plugin fixtures only. Each `tc_*` fixture is replaced at registration time with a stub that raises `UsageError("--no-testcontainers; user expected to provide service externally")`. Maker functions called from non-pytest code (scripts, REPL — see §8.6) are unaffected. Accepts `0`/`false`/`no`. | | `PYTEST_TESTCONTAINERS_REUSE=1` | Reuse mode (§8.5). | | `PYTEST_TESTCONTAINERS_PROJECT` | Override the project name used in reuse names (§8.3). Default: derived from `pyproject.toml [project].name` if a `pyproject.toml` is reachable from `Path.cwd()` walking up; else `"pytest-tc"` with a one-shot stderr warning at first maker invocation (§8.3). | +| `PROJECT_NAME` | Generic project-name source, below `PYTEST_TESTCONTAINERS_PROJECT` and above `COMPOSE_PROJECT_NAME` (§8.3). | +| `COMPOSE_PROJECT_NAME` | docker-compose's standard project var, below `PROJECT_NAME` (§8.3). | +| `PYTEST_TESTCONTAINERS_NAME_TEMPLATE` | Override the container-name template (§8.7). | | `PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK=1` | Skip Docker `ping()` (rare, for debugging in environments where ping returns false-negatives). | | `PYTEST_TESTCONTAINERS_QUIET=1` | Suppress all one-shot stderr advisory notices (docker-exec fallback warning §4.6, project-name fallback warning §8.3). Errors still surface; only informational tips are silenced. | @@ -984,6 +987,7 @@ No TOML config table. The handful of toggles read at maker-call time: | `--testcontainers-reuse` | Same as `PYTEST_TESTCONTAINERS_REUSE=1`. | | `--testcontainers-no-reuse` | Force function-scoped mode regardless of env. | | `--testcontainers-project=NAME` | Override `PYTEST_TESTCONTAINERS_PROJECT`. | +| `--testcontainers-name-template=T` | Override the container-name template (§8.7). Highest precedence. | | `--testcontainers-clean` | Stop+remove all `-tc-*` containers and exit pytest with code 0. | Precedence: CLI > env > defaults. @@ -995,10 +999,13 @@ component resolved as: 1. CLI `--testcontainers-project=NAME` if given. 2. Env `PYTEST_TESTCONTAINERS_PROJECT` if set. -3. `pyproject.toml [project].name` from the nearest `pyproject.toml` - walking up from `Path.cwd()`. We use `tomllib` (stdlib >=3.11; on - 3.10 we depend on `tomli`). -4. Fallback literal `"pytest-tc"`. +3. Env `PROJECT_NAME` if set. +4. Env `COMPOSE_PROJECT_NAME` if set. +5. `pyproject.toml [project].name` from the nearest `pyproject.toml` + walking up from `Path.cwd()` (`tomllib` ≥3.11; `tomli` on 3.10). +6. The cwd directory basename. +7. Fallback literal `"pytest-tc"` (only when the cwd basename is empty, + i.e. the filesystem root) — this is what triggers the one-shot advisory. Justification for using `pyproject.toml [project].name` as default: - The project already names itself there for PyPI; reusing it avoids @@ -1209,6 +1216,32 @@ with make_postgres(image="iplweb/bpp-dbserver:psql-16.13", This is fully supported and will not be broken in v1.x. +### 8.7 Container name template + +Every container the plugin starts — in **both** default and reuse mode — is +named by expanding a template and passing the result through `finalize_name` +(§8.4 sanitization + 255-char trim/checksum). + +Default template: `{project}-tc-{service}-{branch}-{dirtag}-{worker}`. + +Placeholders: `{project}` (§8.3), `{service}` (slug), `{branch}` +(`.git/HEAD`, no subprocess; detached → 12-char SHA; no repo → empty), +`{dir}` (worktree-root basename), `{dirtag}` (8-hex blake2s of the +worktree-root absolute path), `{worker}` (xdist id), `{rand}` +(`secrets.token_hex(2)` — empty in reuse mode for byte-stable find-or-create; +in default mode auto-appended when the template omits it). Empty placeholders +collapse with no dangling dashes. An unknown placeholder raises +`NameTemplateError`. + +Template precedence: `--testcontainers-name-template` > +`PYTEST_TESTCONTAINERS_NAME_TEMPLATE` > +`pyproject.toml [tool.pytest_testcontainers].name_template` > built-in default. + +Reuse-mode names are now per-(project, service, branch, directory, worker); +switching branch/worktree creates a new container and leaves the old one until +`--testcontainers-clean`. The maker `reuse_name=` argument still overrides the +whole scheme (sanitized verbatim, not templated). + --- ## 9. Error handling From ea9d9bd02c3c3c8102fd97768ad344719ca52db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 22:06:37 +0200 Subject: [PATCH 11/13] Fix stale SPEC 8.3 intro: project name now feeds the name template Co-Authored-By: Claude Opus 4.8 (1M context) --- SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2357ad4..4af423f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -994,8 +994,8 @@ Precedence: CLI > env > defaults. ### 8.3 Project name resolution (for reuse names) -The reuse name is `-tc--`. Project -component resolved as: +The `{project}` placeholder in the container-name template (§8.7) resolves +as: 1. CLI `--testcontainers-project=NAME` if given. 2. Env `PYTEST_TESTCONTAINERS_PROJECT` if set. From 9edad58b3e949f316d0411cfb40c9a2abb409bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 22:13:07 +0200 Subject: [PATCH 12/13] Validate miscased/typo'd name-template placeholders Widen the placeholder regex so any {token} is checked against the valid set; previously only lowercase tokens were validated, so {Project}/{dir2} silently passed through and got mangled instead of raising NameTemplateError. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pytest_testcontainers/reuse.py | 2 +- tests/test_reuse.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pytest_testcontainers/reuse.py b/src/pytest_testcontainers/reuse.py index 8ac8cfd..8447e60 100644 --- a/src/pytest_testcontainers/reuse.py +++ b/src/pytest_testcontainers/reuse.py @@ -220,7 +220,7 @@ def sanitize_component(component: str) -> str: DEFAULT_NAME_TEMPLATE = "{project}-tc-{service}-{branch}-{dirtag}-{worker}" VALID_PLACEHOLDERS = frozenset({"project", "service", "branch", "dir", "dirtag", "worker", "rand"}) -_PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}") +_PLACEHOLDER_RE = re.compile(r"\{([^}]+)\}") def _read_pyproject_name_template(start: Path) -> str | None: diff --git a/tests/test_reuse.py b/tests/test_reuse.py index 636cf66..1c6affe 100644 --- a/tests/test_reuse.py +++ b/tests/test_reuse.py @@ -214,6 +214,17 @@ def test_render_unknown_placeholder_raises() -> None: assert "project" in str(exc.value) # message lists valid placeholders +def test_render_rejects_miscased_placeholder() -> None: + with pytest.raises(NameTemplateError) as exc: + reuse._render_template("{Project}-{service}", {"project": "p", "service": "s"}) + assert "Project" in str(exc.value) + + +def test_render_rejects_placeholder_with_digit() -> None: + with pytest.raises(NameTemplateError): + reuse._render_template("{dir2}", {"project": "p"}) + + def test_finalize_collapses_empty_branch() -> None: # An empty {branch} leaves a double dash that finalize collapses cleanly. rendered = reuse._render_template( From 50975f12e02b536faec104cb7e73088e6718a785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Thu, 18 Jun 2026 22:16:40 +0200 Subject: [PATCH 13/13] Update SPEC narrative to the templated container-name format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace lingering -tc-- references and the stale §8.4 _compose snippet (digest_size=2 / name[:250]) with the shipped finalize_name behavior (digest_size=4, [:246], hash of the full name) and the default template. The normative §8.3 / §8.7 were already correct; this clears the descriptive drift the final review flagged. Co-Authored-By: Claude Opus 4.8 (1M context) --- SPEC.md | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4af423f..3f68ae5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -122,9 +122,9 @@ MIT, matching `testcontainers-python`. Confirmed before publishing. disabled, port-conflict detection on restart of stopped containers. Activated globally via env (`PYTEST_TESTCONTAINERS_REUSE=1`) or CLI (`--testcontainers-reuse`). -- xdist-worker-aware reuse names: - `-tc--`, where `worker_id` defaults to - `master` for non-xdist runs. +- xdist-worker-aware container names: the worker id is one component + (`{worker}`) of the templated name (§8.7), defaulting to `master` + for non-xdist runs. - Normalized `pytest.UsageError` when Docker daemon is unreachable (§7). - `atexit` safety net for cleanup when `pytest_unconfigure` is skipped @@ -934,16 +934,19 @@ fixture (which we don't redefine — it's already in pytest-xdist). ### 7.4 Per-worker reuse-mode names -Reuse names are `-tc--`. With `-n 8`: +Container names embed the xdist worker id via the `{worker}` template +component (§8.7), so each worker gets its own container. With `-n 8` the +names differ only in that trailing component (the shared +project/service/branch/dirtag prefix is shown here as `…`): ``` -myproject-tc-psql-gw0 -myproject-tc-psql-gw1 +…-gw0 +…-gw1 … -myproject-tc-psql-gw7 +…-gw7 ``` -For non-xdist runs: `myproject-tc-psql-master`. +For non-xdist runs it is `master` (e.g. `…-master`). Subtlety: a previous run with `-n 8 --testcontainers-reuse` leaves 8 named containers. A subsequent `-n 4` reuses 4 of them and ignores @@ -1093,22 +1096,23 @@ safe but pass through `_sanitize` defensively. `` from pytest-xdist is `master` or `gw` (n in 0..255), already safe. -After composition the full name is bounded by Docker's 255-char -limit. Components are not individually capped; if a user has an -extreme `` name and 8 workers' worth of long suffixes, the -plugin truncates the composed string to 255 chars and appends a -4-char hex hash of the original to keep uniqueness: +After rendering, the full name is bounded by Docker's 255-char limit. +Components are not individually capped; the plugin sanitizes the whole +rendered name and, if it exceeds 255 chars, truncates to 246 and appends +an 8-char hex hash of the *full untruncated* name to keep uniqueness +(`finalize_name`): ```python -def _compose(project: str, service: str, worker: str) -> str: - name = f"{project}-tc-{service}-{worker}" - if len(name) <= 255: - return name - h = hashlib.blake2s(name.encode(), digest_size=2).hexdigest() # 4 chars - return name[:250] + "-" + h +def finalize_name(name: str) -> str: + s = sanitize_component(name) + if len(s) <= 255: + return s + h = hashlib.blake2s(name.encode(), digest_size=4).hexdigest() # 8 chars + return s[:246] + "-" + h ``` -In practice this never fires; included for robustness. +Branch names make long names more likely than the old worker-only +suffix, so this guard matters more than it once did. ### 8.5 Reuse mode mechanics @@ -1184,8 +1188,8 @@ Behavior outside pytest: random `/tmp` dir, this falls back to literal `pytest-tc` — set `PYTEST_TESTCONTAINERS_PROJECT` if you care. - **Worker-id component** (§7.3) defaults to `master` outside - pytest-xdist (the env var is unset), so reuse names are - `-tc--master`. A script and a pytest run on the + pytest-xdist (the env var is unset), so the name's `{worker}` + component is `master`. A script and a pytest run on the same machine without `-n` will share that container; with `-n N` they don't (workers use `gw0..gwN-1`). This is desired — it lets a `manage.py runserver` reuse the same container that `pytest` @@ -1592,7 +1596,7 @@ machinery is no longer in #1. | `BPP_TESTCONTAINERS_REUSE=1` env | #1: `PYTEST_TESTCONTAINERS_REUSE=1` | | | Hard-coded PG image `iplweb/bpp_dbserver:psql-16.13` | User-side: `make_postgres(image=…)` in their fixture | Default in #1 is `postgres:16`. | | Hard-coded user `bpp` / pass `password` / db `bpp` | User-side: `make_postgres(username=…, password=…, database=…)` | Default in #1 is `test`/`test`/`test`. | -| `_PG_NAME = "bpp-tc-pg"` / `_REDIS_NAME = "bpp-tc-redis"` | #1: `-tc--` (§8.3) | Project name resolved from `pyproject.toml [project].name`. | +| `_PG_NAME = "bpp-tc-pg"` / `_REDIS_NAME = "bpp-tc-redis"` | #1: templated name, default `{project}-tc-{service}-{branch}-{dirtag}-{worker}` (§8.7) | Project name resolved from `pyproject.toml [project].name`. | | `os.environ["DJANGO_BPP_DB_HOST"] = …` (5 vars) | #2 (`pytest-testcontainers-django`) | This was the eager-start env-injection. Out of #1 entirely. | | `os.environ["DJANGO_BPP_TEST_TEMPLATE"] = "bpp"` | #3 (`django-pg-baseline`) | TEMPLATE wiring is Django-creation-machinery layer. | | `os.environ["DJANGO_BPP_SKIP_DOTENV"] = "1"` | #2 | Suppressing `.env` re-read after env injection — Django-specific. |