From 4811a78a34af2708f587b8c6110cf7760e8b1b7c Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Wed, 7 Oct 2026 20:57:49 +0000 Subject: [PATCH 1/4] Materialize signed-off spec for #16 --- plan/issue-16.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ spec/issue-16.md | 20 +++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 plan/issue-16.md create mode 100644 spec/issue-16.md diff --git a/plan/issue-16.md b/plan/issue-16.md new file mode 100644 index 0000000..7ef3ff6 --- /dev/null +++ b/plan/issue-16.md @@ -0,0 +1,74 @@ +# Plan: #16, use hyphens in generated GitHub repository URLs + +## What I found in the tree + +- Only three template lines build `github.com/{{ github_username }}/{{ project_slug }}`: `template/pyproject.toml.jinja:28-29` and `template/README.md.jinja:3` and `:9`. A grep of `template/` for `github.com` and `github_username` finds nothing else. `.pre-commit-config.yaml.jinja` and `README.md.jinja:74` point at third-party repositories. `.claude/settings.json` only lists the bare host. These stay as they are. +- The template already builds the hyphenated name inline as `{{ project_slug | replace('_', '-') }}`. It does this 4 times: `pyproject.toml.jinja:6`, `README.md.jinja:4` (twice) and `:25`, and `docs/source/conf.py.jinja:25`. +- The `project_slug` uses that must keep underscores are: `pyproject.toml.jinja:32` (wheel `packages`), `:114`, `:129`, `:135` and `:149` (import-linter), all of `AGENTS.md.jinja`, `python-api.md.jinja`, and the `src/` and `tests/` file and directory names. +- The tests render with `copier.run_copy(..., vcs_ref="HEAD")`. In copier 9.11.3, `_vcs.py:191-211` copies uncommitted changes from a local HEAD and raises a `DirtyLocalWarning`. So a template edit is visible to the tests before it is committed. You do not need to commit before you go from red to green. +- There is no `graphify-out/` in this worktree, so skip the `graphify update .` step from CLAUDE.md. + +## Decisions the spec left open + +1. **Inline filter, no shared variable.** Write `{{ project_slug | replace('_', '-') }}` directly at each of the 6 places. Do not add a `{% set %}`, and do not add a hidden copier variable with `when: false`. This matches the 4 existing places that do the same. A hidden copier variable would also be saved in `.copier-answers.yml`, and that comes too close to the "no new question" assumption. +2. **The new test does its own light render, not the session `project` fixture.** The fixture runs `uv sync`, so the rendered tree has `.venv/` and `uv.lock`. The editable install's `*.dist-info/METADATA` repeats the `Project-URL` values. A grep over the whole tree (criterion 3) would then scan thousands of files and depend on what `uv` writes. Follow the pattern of `test_default_slug_is_valid_package_name`: call `copier.run_copy` into `tmp_path` with `COPIER_DATA` and no `uv sync`. Parametrize over `include_hexagonal` (`[True, False]`, ids `hexagonal` and `flat`) so both layouts are grepped. Each case takes a few seconds and needs no network. +3. **One test function covers criteria 1–3.** They are all facts about the same render. If one fails, the assertion message shows which. + +## Steps + +### Step 1: Write a failing test (red) + +**File:** `tests/test_template.py`. Add the test after `test_docs_scaffold_renders`, or next to `test_default_slug_is_valid_package_name`. A suggested name is `test_github_urls_use_hyphenated_repo_name`. Add `import tomllib` to the imports. + +The test should: +- Render as described in decision 2. +- Parse `pyproject.toml` with `tomllib`. Assert that `project.urls` equals `{"Homepage": "https://github.com/testuser/test-project", "Repository": "https://github.com/testuser/test-project"}`. +- Collect every `https://github.com/testuser/` in `README.md` with a regex such as `r"github\.com/testuser/([^/)\s\"]+)"`. Assert the list is not empty (4 matches are expected today). Assert every match equals `"test-project"`. +- Walk every file under the destination, skipping `.git/`. Read each one as text and skip any that fail to decode. Assert that no file contains `github.com/testuser/test_project`. The message should list the files that do. +- Have a docstring that says why: the repository name uses hyphens, the package name uses underscores, and the CI badge broke for `auto-shopper`. Match the docstring style of the tests around it. + +**Verify:** `uv run pytest tests/test_template.py -k github_urls -v` fails on the current template, in both parametrized cases, on the pyproject assertion. Record that red run, because the spec requires the test to fail against the current template. + +**Dependencies:** none. + +### Step 2: Fix the template (green) + +**Files:** +- `template/pyproject.toml.jinja:28-29`: replace `{{ project_slug }}` with `{{ project_slug | replace('_', '-') }}` in the `Homepage` and `Repository` URLs. +- `template/README.md.jinja:3`: same change in both URLs (badge image and link). +- `template/README.md.jinja:9`: same change in both URLs (link text and target). + +Do not touch any other `project_slug` use (see the list above). + +**Verify:** +- `uv run pytest tests/test_template.py -k github_urls -v` passes in both cases. +- `grep -rn 'github.com/{{ github_username }}/{{ project_slug }}}' template/` returns nothing. + +**Dependencies:** Step 1, so that the test is red before the fix. + +### Checkpoint: full suite + +- Run `uv run pytest`. It is slow: 2 renders, `uv sync`, pre-commit and a Sphinx build. Pass `timeout` at about 600000 ms and run it in the foreground. All existing tests must stay green. This covers criterion 5: `test_template_renders` checks `src/test_project/`, `test_main_executes` imports `test_project.main`, and `test_make_lint` runs the import-linter contracts. +- `git diff --stat` shows only `tests/test_template.py`, `template/pyproject.toml.jinja` and `template/README.md.jinja`. + +### Step 3: Commit + +Make one commit for the test and one for the fix, or a single `fix:` commit. Both fit the history, which uses separate conventional commits per concern (see `bd38693` and `9515c31`). Suggested messages are `test: assert generated GitHub URLs use hyphenated repo name` and `fix: use hyphens in generated GitHub repository URLs`. + +## Mapping to the acceptance criteria + +| Criterion | Proved by | +|---|---| +| 1 (pyproject URLs) | Step 1, `tomllib` assertion | +| 2 (README URLs) | Step 1, regex over `README.md` | +| 3 (no underscore URL anywhere) | Step 1, file walk over the rendered tree | +| 4 (test exists and fails before the fix) | Step 1 red run, then Step 2 green run | +| 5 (underscores kept for imports) | Existing suite at the checkpoint, with no edits to those lines | + +## Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| The file walk hits binary or odd files | Low | Skip `.git/` and skip files that fail to decode. The render without `uv sync` contains only template output and `.copier-answers.yml`. | +| The fix is applied too broadly and touches `packages` or import-linter lines | High: breaks imports and lint | Edit only the 3 listed lines. `test_make_lint`, `test_main_executes` and `test_generated_tests_pass` would catch a mistake. | +| The full suite needs network access for `uv sync` and pre-commit | Medium | Run Step 1 and Step 2 by themselves first, then the full suite. If the full suite fails only because of the network, report that as such. | diff --git a/spec/issue-16.md b/spec/issue-16.md new file mode 100644 index 0000000..2a2ce9b --- /dev/null +++ b/spec/issue-16.md @@ -0,0 +1,20 @@ +## Spec + +**What:** Derive the GitHub repository name in generated URLs from `project_slug` with underscores replaced by hyphens (`{{ project_slug | replace('_', '-') }}`), the same derivation the template already uses for the distribution name. Affected lines on `main`: + +- `template/pyproject.toml.jinja:28-29` — `Homepage` and `Repository` URLs +- `template/README.md.jinja:3` — CI badge image and link +- `template/README.md.jinja:9` — "Original repository" link + +**Why:** GitHub repositories are conventionally named with hyphens, while the Python package name needs underscores. Today a project with `project_slug = auto_shopper` gets URLs pointing at `github.com//auto_shopper`, while the repository is `auto-shopper`, so the CI badge and the project URLs are broken in every generated project with an underscore in its slug. Found while generating `mariushelf/auto-shopper`. + +## Acceptance criteria +- [ ] In a project generated with `project_slug = "test_project"` and `github_username = "testuser"`, `pyproject.toml` has `Homepage` and `Repository` equal to `https://github.com/testuser/test-project`. +- [ ] In the same project, every `github.com/testuser/...` URL in `README.md` uses `test-project`, and none uses `test_project`. +- [ ] No other generated file contains `github.com//` with an underscore (checked by grep over the rendered project). +- [ ] A test in `tests/test_template.py` renders the template and asserts the two criteria above, and fails against the current template. +- [ ] Python import paths, the package directory under `src/`, and import-linter contracts still use `project_slug` with underscores (existing tests stay green). + +## Assumptions +- No new copier question (such as `repo_name`) is added; the repository name is derived, as asked. A separate question can be added later if a repository name ever differs from the hyphenated slug. +- Already generated projects are not migrated; `copier update` picks the change up. From 17a41a46ae914006df665812ee7989724a80926c Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Wed, 7 Oct 2026 22:58:46 +0200 Subject: [PATCH 2/4] test: assert generated GitHub URLs use hyphenated repo name Co-Authored-By: Claude Sonnet 5.5 --- tests/test_template.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_template.py b/tests/test_template.py index e971b86..557e96c 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -2,6 +2,7 @@ import re import subprocess +import tomllib from pathlib import Path from types import SimpleNamespace @@ -339,6 +340,51 @@ def test_default_slug_is_valid_package_name(tmp_path): assert slug.isidentifier(), f"slug is not a valid identifier: {slug!r}" +@pytest.mark.parametrize("include_hexagonal", [True, False], ids=["hexagonal", "flat"]) +def test_github_urls_use_hyphenated_repo_name(tmp_path, include_hexagonal): + """Generated GitHub URLs name the repository with hyphens, not underscores. + + GitHub repositories are conventionally hyphenated while the Python package + needs underscores. Building the URL from ``project_slug`` unchanged broke + the CI badge and project URLs for e.g. ``auto-shopper``. We render without + ``uv sync`` (which would add ``.venv/`` metadata that repeats the URLs) and + check pyproject.toml, README.md, and every other rendered file. + """ + dst = tmp_path / "generated" + copier.run_copy( + src_path=str(TEMPLATE_ROOT), + dst_path=str(dst), + data={**COPIER_DATA, "include_hexagonal": include_hexagonal}, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + repo_url = "https://github.com/testuser/test-project" + + pyproject = tomllib.loads((dst / "pyproject.toml").read_text(encoding="utf-8")) + assert pyproject["project"]["urls"] == { + "Homepage": repo_url, + "Repository": repo_url, + } + + readme = (dst / "README.md").read_text(encoding="utf-8") + repos = re.findall(r"github\.com/testuser/([^/)\]\s\"]+)", readme) + assert repos, "README.md has no github.com/testuser URL" + assert set(repos) == {"test-project"}, f"README repo names: {repos}" + + offenders = [] + for path in dst.rglob("*"): + if not path.is_file() or ".git" in path.relative_to(dst).parts[:1]: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if "github.com/testuser/test_project" in text: + offenders.append(str(path.relative_to(dst))) + assert not offenders, f"underscored repo URL found in: {offenders}" + + @pytest.mark.parametrize("workflow_path", CI_WORKFLOWS, ids=lambda p: p.name) def test_ci_gate_covers_every_job(workflow_path): """The `ci-gate` job must depend on every other job in its workflow. From 2be5cb318aaa3a9bfe096bb6b4d987cf7263ace9 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Wed, 7 Oct 2026 22:58:46 +0200 Subject: [PATCH 3/4] fix: use hyphens in generated GitHub repository URLs Derive the repository name from project_slug with underscores replaced by hyphens, as already done for the distribution name. Import paths and import-linter contracts keep the underscored slug. Co-Authored-By: Claude Sonnet 5.5 --- template/README.md.jinja | 4 ++-- template/pyproject.toml.jinja | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template/README.md.jinja b/template/README.md.jinja index 2912efb..a62231d 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -1,12 +1,12 @@ # {{ project_name }} -[![Tests](https://github.com/{{ github_username }}/{{ project_slug }}/actions/workflows/ci.yaml/badge.svg)](https://github.com/{{ github_username }}/{{ project_slug }}/actions/workflows/ci.yaml) +[![Tests](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}/actions/workflows/ci.yaml/badge.svg)](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}/actions/workflows/ci.yaml) [![PyPI version](https://badge.fury.io/py/{{ project_slug | replace('_', '-') }}.svg)](https://pypi.org/project/{{ project_slug | replace('_', '-') }}/) {{ project_short_description }} -Original repository: [https://github.com/{{ github_username }}/{{ project_slug }}](https://github.com/{{ github_username }}/{{ project_slug }}) +Original repository: [https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}) {% if include_hexagonal -%} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9e7aaa5..fd050d4 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -25,8 +25,8 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/{{ github_username }}/{{ project_slug }}" -Repository = "https://github.com/{{ github_username }}/{{ project_slug }}" +Homepage = "https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}" +Repository = "https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}" [tool.hatch.build.targets.wheel] packages = ["src/{{ project_slug }}"] From c32a50647fbb742d2719ebc2bd267440734c12b3 Mon Sep 17 00:00:00 2001 From: Marius Helf Date: Thu, 8 Oct 2026 16:39:28 +0200 Subject: [PATCH 4/4] refactor: derive GitHub repo name once in a computed repo_name variable Add a hidden `repo_name` variable to copier.yml (`when: false`, so it is not asked in the interview and not stored in the answers file), defaulting to `project_slug | replace('_', '-')`. The GitHub URLs in pyproject.toml and README.md now use `{{ repo_name }}` instead of repeating the replacement rule in each place. Co-Authored-By: Claude Opus 5.5 --- copier.yml | 8 ++++++++ template/README.md.jinja | 4 ++-- template/pyproject.toml.jinja | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/copier.yml b/copier.yml index f19c7da..2d1d192 100644 --- a/copier.yml +++ b/copier.yml @@ -51,6 +51,14 @@ project_slug: and underscores only, and it may not start with a digit. {% endif %} +# Computed, not asked: `when: false` hides it from the interview and keeps it +# out of .copier-answers.yml. GitHub repositories are named with hyphens, while +# the Python package name (project_slug) needs underscores. +repo_name: + type: str + default: "{{ project_slug | replace('_', '-') }}" + when: false + project_short_description: type: str help: A short description of the project diff --git a/template/README.md.jinja b/template/README.md.jinja index a62231d..2eaff08 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -1,12 +1,12 @@ # {{ project_name }} -[![Tests](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}/actions/workflows/ci.yaml/badge.svg)](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}/actions/workflows/ci.yaml) +[![Tests](https://github.com/{{ github_username }}/{{ repo_name }}/actions/workflows/ci.yaml/badge.svg)](https://github.com/{{ github_username }}/{{ repo_name }}/actions/workflows/ci.yaml) [![PyPI version](https://badge.fury.io/py/{{ project_slug | replace('_', '-') }}.svg)](https://pypi.org/project/{{ project_slug | replace('_', '-') }}/) {{ project_short_description }} -Original repository: [https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}](https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}) +Original repository: [https://github.com/{{ github_username }}/{{ repo_name }}](https://github.com/{{ github_username }}/{{ repo_name }}) {% if include_hexagonal -%} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index fd050d4..4505f7a 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -25,8 +25,8 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}" -Repository = "https://github.com/{{ github_username }}/{{ project_slug | replace('_', '-') }}" +Homepage = "https://github.com/{{ github_username }}/{{ repo_name }}" +Repository = "https://github.com/{{ github_username }}/{{ repo_name }}" [tool.hatch.build.targets.wheel] packages = ["src/{{ project_slug }}"]