Skip to content

Hey Justin, I'm still using this one! - #88

Open
mpasternak wants to merge 45 commits into
justinmayer:masterfrom
iplweb:master
Open

mpasternak wants to merge 45 commits into
justinmayer:masterfrom
iplweb:master

Conversation

@mpasternak

@mpasternak mpasternak commented May 8, 2026 •

Copy link
Copy Markdown

Hi Justin, I hope this message finds you well

I hate reviewing large PRs and I would probably close this one, point blank :-)

This is a PR that upgrades many of the crust accumulated over years -> uv, recent Python/Django versions, code upgrades etc. This PR also pulls in pytest and testcontainers for testing this over real PostgreSQL

THIS PR ALSO fixes TOCTOU bug in our code, that occured on PostgreSQL when django-autoslug models were used in concurrent processess. 467c325 This one. It will work great in SQLite, but it will break on concurrent runs.

A lot of code, oldest commit from 2017. Well.

Dare to merge? :-)

mpasternak and others added 30 commits December 10, 2017 21:58
fix assertion error on empty slug
…b fork

Resolved conflicts preferring upstream where it improves project health:
- Took upstream's modern Python 3.9+ / Django 3.2+ requirements, GitHub
  Actions CI, ReadTheDocs config, and pyproject.toml.
- Removed dead .travis.yml.
- Kept iplweb fork identity: package name 'django-autoslug-iplweb' in
  setup.py / pyproject.toml, README badges pointing to iplweb fork URLs,
  and [bumpversion] sections in setup.cfg.
- Generated PEP 621 pyproject.toml with all metadata from setup.py/setup.cfg
- Initialized uv (uv.lock gitignored as this is a library)
- Removed obsolete packaging files: setup.py, setup.cfg, MANIFEST.in,
  _version_helper.py, requirements/*.txt
- Updated docs/conf.py to import __version__ from autoslug package directly
- Note: [bumpversion] config was removed from setup.cfg; will be re-added
  in Step 2 (version consolidation)
- Restored bumpversion-style config in pyproject.toml (was in setup.cfg)
- Replaced legacy bumpversion with maintained bump-my-version fork
- Both pyproject.toml and autoslug/__init__.py now bump together via
  'uv run bump-my-version bump <part>'
- Preserved original release-segment scheme (dev/gamma) from setup.cfg
- Added bump-my-version to dev dependencies
- Added .pre-commit-config.yaml with ruff lint+format and standard
  hygiene hooks (trailing-whitespace, detect-private-key, etc.)
- Configured ruff in pyproject.toml with basic flake8-equivalent rules
  (E, F, W) targeting py39
- Hooks run only on staged files - no existing code reformatted in bulk
- Run 'pre-commit install' (already done in this repo) to enable
- Added pytest, pytest-django, pytest-cov to dev dependencies
- Added [tool.pytest.ini_options] in pyproject.toml with
  DJANGO_SETTINGS_MODULE = 'autoslug.tests.settings'
- Created autoslug/tests/settings.py mirroring the inline configuration
  previously in run_tests.py
- Updated GitHub Actions workflow to use uv + pytest (was pip + run_tests.py)
- Removed run_tests.py, runcover.sh, tox.ini (legacy py35-py38 envlist)
- Tests verified: 32 passed, 1 failed, 1 xfailed (same result as run_tests.py;
  the 1 failure is a pre-existing upstream test, not a runner regression)

Note: the deploy job (autopub) still uses pip - left untouched; may need
adjustment if autopub does not yet read pyproject.toml-only projects.
Repo has been on git for years; these files no longer serve any purpose.
Verified autopub 0.4.1 supports PEP 621 / pure-pyproject.toml projects with
setuptools.build_meta backend (autopub just delegates to 'python -m build').

- Pinned autopub to 0.4.1 (avoid the 1.0 alpha line, which lacks a
  setuptools plugin)
- Slimmed install line: dropped poetry, githubrelease, httpx==0.18.2
  (stale workaround), and wheel (modern build handles it)
- Bumped actions/checkout@v3 -> v4, actions/setup-python@v4 -> v5
- Use Python 3.12 for the deploy job (was 3.9; deploy doesn't need to
  match the test matrix)
- Use GITHUB_REPOSITORY env var instead of ${{ github.repository }}
  interpolation in the shell command (idiomatic, avoids any potential
  injection edge case)
- Removed unrecognized 'build-system = "setuptools"' key from
  [tool.autopub] (autopub 0.4.1 doesn't read it)
Django 4.2 LTS reached extended-EOL on 2026-04-07. Currently in support:
5.2 LTS (until 2028-04) and 6.0. Next LTS will be 6.2 (April 2027).

- requires-python: 3.9 -> 3.12 (lowest Python supported by Django 5.2/6.0
  intersection)
- dependencies: django>=3.2 -> django>=5.2
- classifiers: keep only Framework :: Django 5.2 and 6.0
- ruff target-version: py39 -> py312
- CI matrix: Python 3.12-3.14 x Django 5.2/6.0 (no exclusions needed -
  every cell is supported by Django's compatibility table)

Tests pass on Django 6.0 with the same baseline (32 passed, 1 xfailed,
1 pre-existing failure).
- pyupgrade --py312-plus: auto-modernize Python syntax to py3.12 idioms
- django-upgrade --target-version 5.2: auto-modernize Django patterns,
  using lowest supported Django version as the rewrite target so changes
  stay safe across the 5.2/6.0 matrix
- Both hooks run on staged files only (pre-commit default), so they
  modernize new/changed code without touching legacy upstream code
- Verified against the current tree: no rewrites needed (the upstream
  sync already brought the codebase to a modern py3 / Django baseline)
Django 6.0+ calls Field.pre_save() twice during INSERT compilation:
once via Model.save_base() and a second time via the SQL compiler's
pre_save_val (django/db/models/sql/compiler.py:1751). Without a guard,
the second call re-runs slugify on the already-finalized slug. For
non-idempotent slugify functions (e.g. one that replaces '-' with '_')
this corrupts the value: 'hello_world-2' becomes 'hello_world_2',
mangling the uniqueness suffix.

This was the root cause of the test_custom_slugifier failure that we
inherited from upstream — upstream did not yet account for the Django 6
double-call.

Fix: guard pre_save with a per-instance sentinel attribute set on the
first call within an INSERT cycle. A post_save signal handler clears
the sentinel so subsequent save() calls (e.g. updates, or re-saving
after manual modification) still process the slug normally.

Also pin the test matrix's Django version reliably:

- Use 'uv pip install --reinstall-package django' to actually downgrade
  Django to the matrix version (the previous 'uv pip install' was a
  no-op because uv saw the already-installed Django as satisfying the
  constraint, even when its version was higher than requested).
- Use 'uv run --no-sync' for subsequent commands so uv doesn't undo the
  pin by re-resolving the project lockfile.

Per-file lint ignores are added to pyproject.toml to suppress
pre-existing upstream legacy issues (E501 line-too-long, E731 lambda
assignment, F401 conditional imports, etc.) so pre-commit can run
cleanly when we touch a legacy file. New files are not in the ignore
list and remain fully linted.
Adds a clear support-matrix table covering the same cells that CI tests
(Python 3.12-3.14 x Django 5.2 LTS / 6.0). Also documents which versions
were intentionally dropped and why, so users hitting an older-version
issue can self-diagnose without filing a bug.
mpasternak and others added 15 commits May 8, 2026 10:05
Background
----------
generate_unique_slug used a plain SELECT to look for slug collisions:

    rivals = manager.filter(**lookups)
    if not rivals:
        return slug

This is a TOCTOU race. Two transactions concurrently generating slugs
for the same input can both SELECT, both see no rival, both return the
same candidate slug, and both then INSERT - the second loses to the
UNIQUE constraint with IntegrityError, and depending on the surrounding
transaction graph this can also surface as a deadlock under PostgreSQL
or MySQL.

Fix
---
Wrap the uniqueness check in transaction.atomic() and use
select_for_update() so concurrent generators on the same slug-prefix
serialize at the database via row-level locks instead of racing.

- transaction.atomic(savepoint=False) nests cheaply if the caller is
  already in a transaction, and opens one if they are not.
- NotSupportedError is caught for backends that don't support SELECT
  FOR UPDATE (notably SQLite). The DB's UNIQUE constraint remains the
  ultimate guarantee.
- .exists() materializes the lock-acquiring query without pulling rows.

Tests
-----
Added autoslug/tests/test_concurrency.py with two threading-based
TransactionTestCase tests:

- test_concurrent_create_does_not_collide
- test_concurrent_create_no_deadlock

Both are skipped on SQLite because SQLite drops SELECT FOR UPDATE and
serializes all writers anyway, masking the bug. To exercise them, point
DJANGO_SETTINGS_MODULE at a settings file using PostgreSQL or MySQL.
Adds an alternative Django settings module that spins up a PostgreSQL
container at test-session start, plus a CI job that uses it. This
finally lets us verify the TOCTOU fix from ab968d2 against a backend
that actually does row-level locking.

Verification (locally, against Postgres 16):

- Reverted autoslug/utils.py to its pre-ab968d2 state.
- Ran the concurrency tests against the Postgres settings:
    DJANGO_SETTINGS_MODULE=autoslug.tests.settings_postgres uv run pytest
        autoslug/tests/test_concurrency.py
  Both tests failed with multiple
    'duplicate key value violates unique constraint
     autoslug_modelwithuniqueslug_slug_key'
  IntegrityErrors — exactly the race the fix is intended to prevent.
- Restored the fix; both tests pass.

Implementation
--------------

- Added testcontainers[postgres] and psycopg[binary] to dev dependencies.
- New autoslug/tests/settings_postgres.py inherits from the base test
  settings, starts a postgres:16-alpine container at import time, and
  wires DATABASES to the container's DSN. atexit stops the container.
- New autoslug/tests/conftest.py auto-skips two upstream tests that pass
  oversize values to a CharField. SQLite accepts those silently;
  Postgres strictly rejects with DataError. Skipping is cleaner than
  rewriting upstream tests we did not author.
- New CI job 'test-postgres' runs the suite with the Postgres settings
  module on Python 3.12/3.14 x Django 5.2/6.0 (4 cells; the SQLite job
  already covers the full 3x2 matrix and the Postgres job's purpose is
  specifically to validate row-locking behaviour).
- The deploy job now waits on both 'test' and 'test-postgres'.

GitHub Actions ubuntu-latest runners have Docker pre-installed, so
testcontainers works without extra setup.
- Remove pypi/format badge (uninformative)
- Remove pypi/status badge (redundant with version)
- Remove pypi/dd badge (deprecated by shields.io, has been broken for years)
- Remove ReadTheDocs badge — pointed to upstream's RTD project, not the
  iplweb fork (which has no separate RTD project)
- Use direct GitHub Actions badge URL instead of shields.io proxy (more
  reliable, no CDN cache issues)
- Add pypi/djversions badge (supported Django versions, derived from
  the Framework :: Django :: X.Y classifiers)
- Add license badge linking to COPYING.LESSER
- Add :alt: text for each badge for accessibility / non-image renderers
The dynamic shields.io `pypi/pyversions` and `pypi/djversions` badges
read classifiers from the latest PyPI release; that release was
published with stale/missing Django classifiers, so the Django badge
rendered as "missing". Replace both with static shields.io badges that
match the supported versions matrix already documented below them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sync bumpversion current_version with the released version (1.9.9 -> 1.10.0);
pyproject `version` and autoslug/__init__.py were already at 1.10.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsQ5CxHBLmnruLLxfEfab5
Publishing used to hang off the `deploy` job in main.yml: every push to
master ran autopub, which published only if a RELEASE.md happened to be
sitting in the tree. Two problems with that, and the last release hit both.

The gate is invisible. Whether a push releases depends on a file that a
contributor may or may not have added in a pull request, so "merge this"
and "release this" are the same action with an unclear outcome.

And the job is not atomic. autopub's deploy commits the changelog, pushes
the tag, creates the GitHub release, and only then uploads to PyPI. The
GitHub release step returned 401 (hand-managed GH_TOKEN secret), so the
run died having already pushed tag v1.9.10 with nothing behind it on
PyPI. That tag is still orphaned.

So: publishing moves out to release.yml, triggered by a `v*` tag, which
is what the sibling packages in this family already do. main.yml keeps
only the tests.

release.yml is three jobs. `build` refuses to continue unless the tag
agrees with all three places this package keeps its version -- pyproject
`version`, autoslug.__version__ and the bumpversion `current_version` --
then runs the suite, builds, and twine-checks. `publish` is the only job
with `id-token: write`, and holds nothing else. `github-release` uses the
workflow's own GITHUB_TOKEN, which cannot expire the way GH_TOKEN did,
and takes its body from the CHANGELOG.rst section for that version.

Authentication is PyPI Trusted Publishing (OIDC), so secrets.PYPI_PASSWORD
and secrets.GH_TOKEN are gone from the YAML with no replacement. This
needs a matching publisher on PyPI (Manage -> Publishing): owner iplweb,
repo django-autoslug-iplweb, workflow release.yml, environment pypi.
Without it the first tagged release fails with `invalid-publisher`.

bump-my-version now tags (`tag = true`, `tag_name = "v{new_version}"`),
so cutting a release is one command plus a push. The autopub config is
removed from pyproject.toml and CONTRIBUTING.rst documents the new flow
in place of the RELEASE.md instructions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore: publish to PyPI over trusted publishing, from a tag
A release is a chain of three jobs and a pushed tag fires it exactly once.
When a link past the first one breaks -- a failed upload, an expired token,
a transient GitHub outage -- the tag already exists, so nothing will start
the chain again and the missing half cannot be redone. That is how tag
v1.9.10 came to exist with nothing published behind it; deleting and
re-pushing a tag was the only remedy.

Add workflow_dispatch beside the tag trigger so the same run can be started
again against the tag it was meant for.

The dispatch dialog will happily point at a branch, and every step here
reads the version out of github.ref_name -- from master that is "master",
which would be compared against pyproject/__init__/bumpversion as if it were
a version, and cut a GitHub release named after a branch. So the build job
now begins by refusing any ref that is not a tag, and says to re-run with
"Use workflow from" set to the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci: allow a release to be re-run by hand from its tag

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants