Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The refactor adds a copier variable repo_name, the name the spec assumption excludes. The plan also rejected a hidden when: false variable. The variable is not asked, but --data repo_name=custom-repo overrides it. A render showed custom-repo in the URLs. The override is undocumented, and copier does not save it.
Suggested fix: A human decides. To keep the variable, document the --data override in help or the README. Otherwise revert c32a506 to the inline {{ project_slug | replace('_', '-') }} filter. That filter matches the four existing uses.

~Written by Claude, run via the agentic engineering loop

type: str
default: "{{ project_slug | replace('_', '-') }}"
when: false

project_short_description:
type: str
help: A short description of the project
Expand Down
74 changes: 74 additions & 0 deletions plan/issue-16.md
Original file line number Diff line number Diff line change
@@ -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/<repo>` 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. |
20 changes: 20 additions & 0 deletions spec/issue-16.md
Original file line number Diff line number Diff line change
@@ -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/<user>/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/<github_username>/<project_slug>` 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.
4 changes: 2 additions & 2 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
@@ -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 }}/{{ 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 }}](https://github.com/{{ github_username }}/{{ project_slug }})
Original repository: [https://github.com/{{ github_username }}/{{ repo_name }}](https://github.com/{{ github_username }}/{{ repo_name }})

{% if include_hexagonal -%}

Expand Down
4 changes: 2 additions & 2 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}/{{ repo_name }}"
Repository = "https://github.com/{{ github_username }}/{{ repo_name }}"

[tool.hatch.build.targets.wheel]
packages = ["src/{{ project_slug }}"]
Expand Down
46 changes: 46 additions & 0 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re
import subprocess
import tomllib
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -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.
Expand Down
Loading