Compare commits

..
53 Commits
Author SHA1 Message Date
CI Bot f28d3c4844 chore: release v2.1.0 [skip ci] 2026-07-11 08:56:19 +00:00
brian 1cbcc1c19b Merge pull request '[minor] Add runtime-checkable Protocol typing to public interfaces' (#57) from cursor/protocol-abc-interfaces into main
Test Python Package / unit-tests (push) Successful in 10s
Release on merge to main / release (push) Successful in 15s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / integration-tests (push) Successful in 20s
Test Python Package / coverage-report (push) Successful in 7s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/57
2026-07-11 10:56:01 +02:00
Brian Bjarke JensenandCursor a0e5c9dcca Document test organization and add Cursor workflow rules.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 13s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 1m14s
Test Python Package / coverage-report (pull_request) Successful in 12s
Record the one-to-one unit test file convention in README and add rules for test placement and copy-ready PR text after first push.

Co-authored-by: Cursor <[email protected]>
2026-07-11 10:50:51 +02:00
Brian Bjarke JensenandCursor 44b15cb21a Move structural typing tests into interface unit test files.
Code Quality Pipeline / code-quality (pull_request) Successful in 24s
PR Title Check / check-title (pull_request) Successful in 35s
Test Python Package / coverage-report (pull_request) Successful in 11s
Test Python Package / unit-tests (pull_request) Successful in 12s
Test Python Package / integration-tests (pull_request) Successful in 26s
Align test layout with the one-test-file-per-interface convention by removing structural_typing_test.py.

Co-authored-by: Cursor <[email protected]>
2026-07-11 10:32:17 +02:00
Brian Bjarke JensenandCursor f6ee9a3724 Add runtime-checkable Protocol typing to all public interfaces.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 9s
Code Quality Pipeline / code-quality (pull_request) Successful in 22s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 8s
Enables structural subtyping for consumers while preserving nominal adapter inheritance, instantiation guards, and scan_keys defaults.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:45:42 +02:00
brian 0829354cc4 Merge pull request 'Add async future note and remove redundant pytest pythonpath' (#56) from cursor/readme-async-and-test-path-cleanup into main
Release on merge to main / release (push) Successful in 7s
Test Python Package / unit-tests (push) Successful in 12s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / integration-tests (push) Successful in 24s
Build CI Image / build-and-push (push) Successful in 49s
Test Python Package / coverage-report (push) Successful in 9s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/56
2026-07-11 09:28:01 +02:00
Brian Bjarke JensenandCursor a0d509a2a3 Format CHANGELOG.md with Prettier.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 1m14s
Test Python Package / coverage-report (pull_request) Successful in 13s
Fixes pre-commit prettier check in CI.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:25:16 +02:00
Brian Bjarke JensenandCursor 9b7b15a67e Document possible async support and drop redundant pytest pythonpath.
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Failing after 23s
PR Title Check / check-title (pull_request) Successful in 32s
Test Python Package / integration-tests (pull_request) Successful in 25s
Test Python Package / coverage-report (pull_request) Successful in 11s
The package is already installed via uv sync, so pytest no longer needs a manual repo-root path.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:22:37 +02:00
CI Bot cd5b84b88e chore: release v2.0.5 [skip ci] 2026-07-10 19:44:15 +00:00
brian 6d02f32ca5 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
Release on merge to main / release (push) Successful in 15s
Test Python Package / integration-tests (push) Successful in 23s
Test Python Package / coverage-report (push) Successful in 10s
Test Python Package / unit-tests (push) Successful in 13s
Code Quality Pipeline / code-quality (push) Successful in 19s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/55
2026-07-10 21:43:59 +02:00
Brian Bjarke JensenandCursor 57b396bcd3 Allow empty payloads in Redis and MinIO adapters.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 20s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 11s
Relax write validation so {} and zero-byte BytesIO round-trip correctly,
document None-vs-empty semantics on interfaces and adapters, and add
integration tests for placeholders and existence distinction.

Co-authored-by: Cursor <[email protected]>
2026-07-10 21:39:51 +02:00
brian 093e538d5b Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
Release on merge to main / release (push) Successful in 7s
Code Quality Pipeline / code-quality (push) Successful in 19s
Test Python Package / unit-tests (push) Successful in 14s
Build CI Image / build-and-push (push) Successful in 38s
Test Python Package / integration-tests (push) Successful in 50s
Test Python Package / coverage-report (push) Successful in 12s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/54
2026-07-10 21:16:27 +02:00
Brian Bjarke JensenandCursor b28ac6e803 Scope integration fixtures per backend subdirectory.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 13s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
Split Redis and MinIO container setup into backend-specific conftest modules so only the containers needed for collected tests are imported and started.

Co-authored-by: Cursor <[email protected]>
2026-07-10 21:11:53 +02:00
brian 6c66fe4e21 Merge pull request 'Add consumer-facing CHANGELOG.md maintained by release CI' (#53) from cursor/consumer-changelog into main
Release on merge to main / release (push) Successful in 7s
Code Quality Pipeline / code-quality (push) Successful in 19s
Test Python Package / unit-tests (push) Successful in 13s
Build CI Image / build-and-push (push) Successful in 36s
Test Python Package / integration-tests (push) Successful in 20s
Test Python Package / coverage-report (push) Successful in 11s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/53
2026-07-10 20:40:34 +02:00
Brian Bjarke Jensen 089a1287f8 Format CHANGELOG.md with Prettier
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / unit-tests (pull_request) Successful in 9s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 10s
2026-07-10 20:38:40 +02:00
Brian Bjarke JensenandCursor 0a5bd39801 Add consumer-facing CHANGELOG.md maintained by release CI.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Failing after 23s
Test Python Package / integration-tests (pull_request) Successful in 45s
Test Python Package / coverage-report (pull_request) Successful in 11s
Backfill version history from git tags and update CHANGELOG automatically on each release alongside Gitea release notes.

Co-authored-by: Cursor <[email protected]>
2026-07-10 20:28:46 +02:00
brian 683ee332d3 Merge pull request '[chore] add repository URL and Python 3.12 packaging metadata' (#52) from cursor/add-packaging-metadata into main
Release on merge to main / release (push) Successful in 7s
Test Python Package / unit-tests (push) Successful in 14s
Code Quality Pipeline / code-quality (push) Successful in 22s
Test Python Package / integration-tests (push) Successful in 27s
Test Python Package / coverage-report (push) Successful in 13s
Build CI Image / build-and-push (push) Successful in 1m0s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/52
2026-07-10 19:50:22 +02:00
Brian Bjarke JensenandCursor 9df5032d44 Add repository URL and Python 3.12 packaging metadata.
Test Python Package / coverage-report (pull_request) Successful in 10s
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 12s
Test Python Package / integration-tests (pull_request) Successful in 24s
Code Quality Pipeline / code-quality (pull_request) Successful in 51s
Co-authored-by: Cursor <[email protected]>
2026-07-10 19:46:21 +02:00
brian 4f6f33f36e Merge pull request 'Drop types-redis in favor of redis inline types' (#51) from cursor/drop-types-redis into main
Release on merge to main / release (push) Successful in 5s
Test Python Package / unit-tests (push) Successful in 12s
Code Quality Pipeline / code-quality (push) Successful in 23s
Build CI Image / build-and-push (push) Successful in 1m3s
Test Python Package / integration-tests (push) Successful in 57s
Test Python Package / coverage-report (push) Successful in 10s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/51
2026-07-10 19:28:20 +02:00
Brian Bjarke JensenandCursor 10d43ce198 Drop types-redis in favor of redis inline types.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 12s
Test Python Package / integration-tests (pull_request) Successful in 25s
Test Python Package / coverage-report (pull_request) Successful in 11s
Code Quality Pipeline / code-quality (pull_request) Successful in 47s
The outdated redis 4.6 stubs conflicted with redis 6+ py.typed annotations used at runtime.

Co-authored-by: Cursor <[email protected]>
2026-07-10 19:24:17 +02:00
CI Bot 4f7b67203a chore: release v2.0.4 [skip ci] 2026-07-10 12:47:15 +00:00
brian a27e9e5763 Merge pull request '[fix] Include optional dependencies in security audit' (#50) from cursor/security-audit-all-extras into main
Test Python Package / unit-tests (push) Successful in 10s
Release on merge to main / release (push) Successful in 14s
Code Quality Pipeline / code-quality (push) Successful in 21s
Test Python Package / integration-tests (push) Successful in 20s
Test Python Package / coverage-report (push) Successful in 8s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/50
2026-07-10 14:47:01 +02:00
Brian Bjarke JensenandCursor fd0045d3e0 Include optional dependencies in security audit.
PR Title Check / check-title (pull_request) Successful in 5s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 21s
Test Python Package / integration-tests (pull_request) Successful in 19s
Test Python Package / coverage-report (pull_request) Successful in 8s
Sync all extras before safety check so redis and minio vulnerabilities are scanned.

Co-authored-by: Cursor <[email protected]>
2026-07-10 14:44:35 +02:00
brian 515f4bbeae Merge pull request 'revert 707f11f578f007c7152582ea524b66dacea50111' (#49) from brian-patch-1 into main
Release on merge to main / release (push) Successful in 5s
Test Python Package / unit-tests (push) Successful in 10s
Code Quality Pipeline / code-quality (push) Successful in 20s
Test Python Package / integration-tests (push) Successful in 19s
Test Python Package / coverage-report (push) Successful in 7s
Build CI Image / build-and-push (push) Successful in 1m32s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/49
2026-07-10 14:42:24 +02:00
brian ef31fb4b4e revert 707f11f578
revert chore: release v2.0.4 [skip ci]
2026-07-10 14:42:09 +02:00
CI Bot 707f11f578 chore: release v2.0.4 [skip ci] 2026-07-10 12:36:58 +00:00
brian b1d34f8f0f Merge pull request '[patch] Unify dev tooling via uv so pre-commit, CI, and the update bot stay aligned' (#48) from chore/align-dev-tooling-via-uv into main
Test Python Package / unit-tests (push) Successful in 13s
Code Quality Pipeline / code-quality (push) Successful in 21s
Test Python Package / integration-tests (push) Successful in 20s
Test Python Package / coverage-report (push) Successful in 8s
Build CI Image / build-and-push (push) Successful in 58s
Release on merge to main / release (push) Failing after 9s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/48
2026-07-10 14:36:44 +02:00
Brian Bjarke JensenandCursor 565879dd52 Unify dev tooling via uv so pre-commit, CI, and the update bot stay aligned.
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 19s
PR Title Check / check-title (pull_request) Successful in 31s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Route Python hooks through uv run with versions pinned in uv.lock, add explicit
Ruff settings, and extend the dependency bot to run pre-commit autoupdate.

Co-authored-by: Cursor <[email protected]>
2026-07-10 14:29:22 +02:00
brian 0d0f430f6f Merge pull request 'Fix automated package publishing in release workflow' (#47) from fix/release-workflow-publish into main
Release on merge to main / release (push) Successful in 5s
Test Python Package / unit-tests (push) Successful in 12s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / integration-tests (push) Successful in 19s
Test Python Package / coverage-report (push) Successful in 8s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/47
2026-07-10 14:17:10 +02:00
Brian Bjarke JensenandCursor 5ac40b1ba5 Publish packages from the release workflow so automated releases reach the registry.
Release commits use [skip ci], which prevents publish.yml from running on tag push.

Co-authored-by: Cursor <[email protected]>
2026-07-10 14:15:04 +02:00
CI Bot fa4ff1f03c chore: release v2.0.3 [skip ci] 2026-07-10 12:06:36 +00:00
brian 0a39151ba0 Merge pull request '[patch] Use structured fields for adapter debug and info logs' (#46) from cursor/structured-adapter-logging into main
Release on merge to main / release (push) Successful in 11s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / coverage-report (push) Successful in 8s
Test Python Package / unit-tests (push) Successful in 10s
Test Python Package / integration-tests (push) Successful in 23s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/46
2026-07-10 14:06:23 +02:00
Brian Bjarke JensenandCursor 82a702db0c Fix mypy error in Minio adapter log assertion.
Test Python Package / unit-tests (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 33s
Test Python Package / integration-tests (pull_request) Successful in 20s
PR Title Check / check-title (pull_request) Successful in 42s
Test Python Package / coverage-report (pull_request) Successful in 14s
Narrow optional bucket name before checking it appears in captured log text.

Co-authored-by: Cursor <[email protected]>
2026-07-10 14:04:36 +02:00
Brian Bjarke JensenandCursor b886a7c147 Use structured fields for adapter debug and info logs.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / unit-tests (pull_request) Successful in 9s
Code Quality Pipeline / code-quality (pull_request) Failing after 14s
Test Python Package / integration-tests (pull_request) Successful in 1m2s
Test Python Package / coverage-report (pull_request) Successful in 11s
Replace f-string log messages with structlog keyword fields so events aggregate cleanly and Redis payloads are not logged verbatim.

Co-authored-by: Cursor <[email protected]>
2026-07-10 14:00:34 +02:00
brian 4518a93a3a Merge pull request 'Migrate Python workflows to python-repositories-ci runner' (#41) from cursor/migrate-python-repositories-ci into main
Release on merge to main / release (push) Successful in 6s
Test Python Package / unit-tests (push) Successful in 15s
Code Quality Pipeline / code-quality (push) Successful in 28s
Test Python Package / integration-tests (push) Successful in 1m20s
Test Python Package / coverage-report (push) Successful in 11s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/41
2026-07-10 13:48:49 +02:00
Brian Bjarke JensenandCursor a8b7b59484 Migrate Python workflows to python-repositories-ci runner label.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / unit-tests (pull_request) Successful in 13s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 26s
Test Python Package / coverage-report (pull_request) Successful in 16s
Co-authored-by: Cursor <[email protected]>
2026-07-10 13:46:10 +02:00
brian 0e6941dccf Merge pull request 'Use lowercase CI image registry owner for Docker compatibility' (#45) from cursor/fix-ci-registry-owner-lowercase into main
Release on merge to main / release (push) Successful in 8s
Test Python Package / integration-tests (push) Successful in 29s
Code Quality Pipeline / code-quality (push) Successful in 48s
Test Python Package / unit-tests (push) Successful in 19s
Test Python Package / coverage-report (push) Successful in 15s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/45
2026-07-10 13:13:01 +02:00
Brian Bjarke JensenandCursor a6428ccd29 Use lowercase CI image registry owner for Docker compatibility.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 1m12s
Code Quality Pipeline / code-quality (pull_request) Successful in 1m31s
Test Python Package / integration-tests (pull_request) Successful in 2m25s
Test Python Package / coverage-report (pull_request) Successful in 22s
Docker requires lowercase repository names; Gitea maps lillevemmelund to the LilleVemmelund org.

Co-authored-by: Cursor <[email protected]>
2026-07-10 13:08:28 +02:00
brian 655fdc4ee8 Merge pull request 'Fix CI image registry owner to match Gitea org slug' (#44) from cursor/fix-ci-registry-owner-case into main
Release on merge to main / release (push) Successful in 8s
Test Python Package / unit-tests (push) Successful in 14s
Code Quality Pipeline / code-quality (push) Successful in 30s
Test Python Package / integration-tests (push) Successful in 59s
Test Python Package / coverage-report (push) Successful in 39s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/44
2026-07-10 13:01:33 +02:00
Brian Bjarke JensenandCursor e031c549e5 Fix CI image registry owner to match Gitea org slug.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 18s
Test Python Package / integration-tests (pull_request) Successful in 23s
Code Quality Pipeline / code-quality (pull_request) Successful in 34s
Test Python Package / coverage-report (pull_request) Successful in 12s
Use LilleVemmelund instead of lille-vemmelund so container registry pushes resolve the correct namespace.

Co-authored-by: Cursor <[email protected]>
2026-07-10 12:58:29 +02:00
brian 49b6238b7d Merge pull request 'Rename CI registry owner from brian to lille-vemmelund' (#43) from cursor/rename-ci-owner-to-lille-vemmelund into main
Release on merge to main / release (push) Successful in 7s
Code Quality Pipeline / code-quality (push) Successful in 31s
Test Python Package / unit-tests (push) Successful in 37s
Test Python Package / integration-tests (push) Successful in 1m14s
Test Python Package / coverage-report (push) Successful in 13s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/43
2026-07-10 12:51:03 +02:00
Brian Bjarke JensenandCursor 0549358fe1 Keep python-utils package index under brian owner.
PR Title Check / check-title (pull_request) Successful in 7s
Code Quality Pipeline / code-quality (pull_request) Successful in 29s
Test Python Package / unit-tests (pull_request) Successful in 1m28s
Test Python Package / integration-tests (pull_request) Successful in 1m48s
Test Python Package / coverage-report (pull_request) Successful in 17s
Only the CI image registry owner changes to lille-vemmelund; python-utils remains published under brian.

Co-authored-by: Cursor <[email protected]>
2026-07-10 12:48:32 +02:00
Brian Bjarke JensenandCursor 33efbd1005 Update CI registry owner from brian to lille-vemmelund.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / integration-tests (pull_request) Failing after 30s
Code Quality Pipeline / code-quality (pull_request) Failing after 1m12s
Test Python Package / unit-tests (pull_request) Failing after 1m28s
Test Python Package / coverage-report (pull_request) Has been skipped
Align CI image paths, package index URLs, and lockfile entries with the new Gitea owner name.

Co-authored-by: Cursor <[email protected]>
2026-07-10 09:32:39 +02:00
brian 24126e4044 Merge pull request 'Fix CI image build failures on runners with isolated DinD DNS' (#42) from fix/ci-image-build-dns into main
Release on merge to main / release (push) Successful in 10s
Test Python Package / unit-tests (push) Successful in 16s
Code Quality Pipeline / code-quality (push) Successful in 39s
Build CI Image / build-and-push (push) Failing after 55s
Test Python Package / integration-tests (push) Successful in 1m11s
Test Python Package / coverage-report (push) Successful in 20s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/42
2026-07-10 09:19:12 +02:00
Brian Bjarke JensenandCursor f3b6cdbc9e Fix CI image build failures on runners with isolated DinD DNS.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 17s
Code Quality Pipeline / code-quality (pull_request) Successful in 51s
Test Python Package / integration-tests (pull_request) Successful in 1m40s
Test Python Package / coverage-report (pull_request) Successful in 17s
Copy the Docker CLI from the official image instead of apt, and use
--network=host during docker build so apt and layer pulls can resolve
external registries on standalone runners.

Co-authored-by: Cursor <[email protected]>
2026-07-10 09:16:19 +02:00
brian fa2554ef5f Merge pull request 'Add nightly CI base image and migrate workflows to python-repositories-ci' (#40) from cursor/ci-base-image into main
Release on merge to main / release (push) Successful in 7s
Test Python Package / unit-tests (push) Successful in 19s
Code Quality Pipeline / code-quality (push) Successful in 31s
Test Python Package / integration-tests (push) Successful in 1m8s
Test Python Package / coverage-report (push) Successful in 39s
Build CI Image / build-and-push (push) Failing after 6m24s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/40
2026-07-09 22:51:31 +02:00
Brian Bjarke JensenandCursor b21fd247c5 Revert workflows to ubuntu-latest until CI image is seeded.
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / unit-tests (pull_request) Successful in 1m4s
Code Quality Pipeline / code-quality (pull_request) Successful in 1m48s
Test Python Package / integration-tests (pull_request) Successful in 2m10s
Test Python Package / coverage-report (pull_request) Successful in 20s
Co-authored-by: Cursor <[email protected]>
2026-07-09 22:47:52 +02:00
Brian Bjarke JensenandCursor 441a21204d Add local CI image build script and document bootstrap flow.
Code Quality Pipeline / code-quality (pull_request) Failing after 1s
Test Python Package / unit-tests (pull_request) Failing after 1s
Test Python Package / integration-tests (pull_request) Failing after 1s
Test Python Package / coverage-report (pull_request) Has been skipped
PR Title Check / check-title (pull_request) Successful in 34s
Co-authored-by: Cursor <[email protected]>
2026-07-09 22:39:33 +02:00
Brian Bjarke JensenandCursor f2a8581de2 Read Python version from .python-version in CI image build.
PR Title Check / check-title (pull_request) Successful in 41s
Code Quality Pipeline / code-quality (pull_request) Failing after 1s
Test Python Package / integration-tests (pull_request) Failing after 34s
Test Python Package / unit-tests (pull_request) Failing after 40s
Test Python Package / coverage-report (pull_request) Has been skipped
Avoid hardcoding 3.12 in the Dockerfile so the CI image tracks the same source of truth as local tooling and uv sync.

Co-authored-by: Cursor <[email protected]>
2026-07-09 22:27:59 +02:00
Brian Bjarke JensenandCursor 3b05260323 Add nightly CI base image and migrate workflows to python-repositories-ci.
Code Quality Pipeline / code-quality (pull_request) Failing after 1s
Test Python Package / unit-tests (pull_request) Failing after 0s
Test Python Package / integration-tests (pull_request) Failing after 0s
PR Title Check / check-title (pull_request) Successful in 6s
Test Python Package / coverage-report (pull_request) Has been skipped
Pre-build Python, uv, and locked deps in a Gitea container image so daily jobs skip repeated bootstrap; document runner registry auth and ci-bot credentials.

Co-authored-by: Cursor <[email protected]>
2026-07-09 22:21:22 +02:00
CI Bot 369295b8ae chore: release v2.0.2 [skip ci] 2026-07-09 16:45:24 +00:00
brian 95c78596e7 Merge pull request '[patch] Use dict[str, Any] for JSON repository document types' (#38) from cursor/json-dict-str-any-typing into main
Test Python Package / integration-tests (push) Successful in 48s
Release on merge to main / release (push) Successful in 12s
Test Python Package / unit-tests (push) Successful in 22s
Code Quality Pipeline / code-quality (push) Successful in 29s
Test Python Package / coverage-report (push) Successful in 12s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/python-repositories/pulls/38
2026-07-09 18:45:10 +02:00
Brian Bjarke JensenandCursor 42e885edd5 Use dict[str, Any] for JSON repository document types.
PR Title Check / check-title (pull_request) Successful in 6s
Code Quality Pipeline / code-quality (pull_request) Successful in 1m28s
Test Python Package / unit-tests (pull_request) Successful in 1m29s
Test Python Package / coverage-report (pull_request) Successful in 16s
Test Python Package / integration-tests (pull_request) Successful in 48s
Clarifies that JSON CRUD operates on string-keyed objects without changing runtime behavior.

Co-authored-by: Cursor <[email protected]>
2026-07-09 18:41:23 +02:00
49 changed files with 1796 additions and 389 deletions
+58
View File
@@ -0,0 +1,58 @@
---
description: After first branch push, provide copy-ready PR title and body
alwaysApply: true
---
# PR Title and Comment After First Push
The user creates pull requests manually. After the **first push** of a feature branch to remote in a session, always end your response with a copy-ready PR title and PR comment.
## When to provide it
- After the first successful `git push` (or `git push -u origin <branch>`) for a branch in the current task
- Do **not** create the PR with `gh pr create` unless explicitly asked
- On later pushes to the same branch, only repeat if the user asks or the change set meaningfully updates what the PR should say
## PR title
- Use a fitting version-bump prefix when changes touch `python_repositories/`:
- `[patch]` or `[fix]` — bug fix
- `[minor]` or `[feat]` — new feature
- `[major]` or `[breaking]` — breaking change
- Docs-, CI-, or test-only changes: no prefix required
- Keep the title concise and descriptive
## PR comment format
Provide two separate fenced code blocks the user can copy directly:
1. **PR title** — single line in a code block
2. **PR comment** — body with `## Summary` and `## Test plan` sections, checklist items reflecting what was actually run
Example structure:
````
PR title:
```
[minor] Short description of the change
```
PR comment:
```
## Summary
- Bullet points of what changed and why
## Test plan
- [x] Commands or checks that were run
```
````
## Notes
- Match the repo PR template ([`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md))
- Be accurate: only mark test-plan items done if they were run
- Keep prose clear; the user copies this verbatim into Gitea
+31
View File
@@ -0,0 +1,31 @@
---
description: Unit test file naming and placement conventions
globs: tests/**/*.py
alwaysApply: false
---
# Test Organization
Unit tests use a one-to-one mapping between source modules and test files.
## Naming
- `python_repositories/<path>/<module>.py` → `tests/unit/<module>_test.py`
- Test files must end with `_test.py` (enforced by pre-commit `name-tests-test`)
## Placement
- Add tests to the existing `*_test.py` for the module under test
- Do not create cross-cutting test files (e.g. `structural_typing_test.py`); colocate with the relevant interface/adapter/config test file
- Shared fixtures → `tests/conftest.py`
- Module-specific helper classes → the matching test file
## Examples
```
json_repository_interface.py → tests/unit/json_repository_interface_test.py
redis_adapter.py → tests/unit/redis_adapter_test.py
redis_config.py → tests/unit/redis_config_test.py
```
Integration tests live under `tests/integration/<backend>/` with the same `_test.py` suffix.
+16
View File
@@ -0,0 +1,16 @@
.git
.github
.gitea
.mypy_cache
.pytest_cache
.ruff_cache
.venv
__pycache__
*.pyc
.coverage
coverage-unit
coverage-integration
coverage.txt
dist
build
*.egg-info
+52
View File
@@ -0,0 +1,52 @@
# Build and publish the per-repo CI base image to the Gitea container registry.
name: Build CI Image
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
push:
branches:
- main
paths:
- pyproject.toml
- uv.lock
- docker/ci/**
env:
REGISTRY: gitea.lille-vemmelund.dk
REGISTRY_USER: ci-bot
IMAGE: gitea.lille-vemmelund.dk/lillevemmelund/python-repositories-ci
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea container registry
env:
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
run: |
echo "$CI_RUNNER_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
- name: Build CI image
env:
CI_RUNNER_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
run: |
echo "$CI_RUNNER_TOKEN" > /tmp/uv_token
docker build --network=host -f docker/ci/Dockerfile \
--secret id=uv_token,src=/tmp/uv_token \
-t "${IMAGE}:latest" \
.
rm -f /tmp/uv_token
- name: Tag and push CI image
run: |
STAMP="$(date -u +%Y%m%d%H%M)"
docker tag "${IMAGE}:latest" "${IMAGE}:${STAMP}"
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${STAMP}"
echo "Pushed ${IMAGE}:latest and ${IMAGE}:${STAMP}"
+5 -11
View File
@@ -8,23 +8,17 @@ on:
jobs:
code-quality:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Install dependencies
- name: Sync dependencies
env:
UV_LINK_MODE: copy
run: uv sync --all-extras
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
- name: Type check with mypy
run: uv run mypy .
+29 -8
View File
@@ -7,24 +7,45 @@ on:
jobs:
update-check:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.CI_RUNNER_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Sync dependencies
env:
UV_LINK_MODE: copy
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras
- name: Upgrade dependencies
env:
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv lock --upgrade
- name: Update pre-commit hook revs
env:
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv run pre-commit autoupdate
- name: Sync upgraded dependencies
env:
UV_LINK_MODE: copy
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras
- name: Smoke check
run: |
uv run ruff check .
uv run ruff format --check .
uv run mypy .
- name: Commit and push changes
env:
API_URL: ${{ vars.API_URL }}
+9 -10
View File
@@ -9,25 +9,24 @@ on:
jobs:
build-and-publish:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Update version in pyproject.toml to match tag
env:
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: scripts/ci/bump-version.sh --from-tag "${GITHUB_REF##*/}"
- name: Build package
env:
UV_LINK_MODE: copy
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: |
uv sync --no-dev
uv sync --no-dev --frozen
uv build
- name: Publish to Gitea Package Registry
+34 -12
View File
@@ -8,7 +8,7 @@ on:
jobs:
release:
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -22,19 +22,12 @@ jobs:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: scripts/ci/parse-merge-commit.sh "$COMMIT_MSG"
- name: Set up Python
if: steps.meta.outputs.bump != 'skip'
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
if: steps.meta.outputs.bump != 'skip'
run: pip install uv
- name: Bump version
if: steps.meta.outputs.bump != 'skip'
id: bump
env:
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: scripts/ci/bump-version.sh "${{ steps.meta.outputs.bump }}"
- name: Generate release notes
@@ -47,6 +40,15 @@ jobs:
NEW_TAG="v${{ steps.bump.outputs.version }}"
scripts/ci/generate-release-notes.sh "$NEW_TAG" "$PR_TITLE" "$PREV_TAG"
- name: Update CHANGELOG
if: steps.meta.outputs.bump != 'skip'
env:
PR_TITLE: ${{ steps.meta.outputs.pr_title }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0)
VERSION="${{ steps.bump.outputs.version }}"
scripts/ci/update-changelog.sh "$VERSION" "$PR_TITLE" "$PREV_TAG"
- name: Commit, tag, and push
if: steps.meta.outputs.bump != 'skip'
env:
@@ -55,7 +57,7 @@ jobs:
run: |
git config user.name "CI Bot"
git config user.email "[email protected]"
git add pyproject.toml uv.lock
git add pyproject.toml uv.lock CHANGELOG.md
git commit -m "chore: release v${VERSION} [skip ci]"
git tag "v${VERSION}"
git remote set-url origin "https://x-access-token:${CI_RUNNER_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
@@ -81,3 +83,23 @@ jobs:
-H "Authorization: token ${CI_RUNNER_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
# Publish here instead of relying on publish.yml tag trigger: release commits use
# [skip ci], which suppresses tag-push workflows, and Gitea does not emit release
# events for releases created by the CI bot account.
- name: Build package
if: steps.meta.outputs.bump != 'skip'
env:
UV_LINK_MODE: copy
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: |
uv sync --no-dev --frozen
uv build
- name: Publish to Gitea Package Registry
if: steps.meta.outputs.bump != 'skip'
env:
UV_PUBLISH_TOKEN: ${{ secrets.CI_RUNNER_TOKEN }}
UV_PUBLISH_URL: ${{ vars.REPOSITORY_URL }}
run: uv publish
+5 -11
View File
@@ -7,23 +7,17 @@ on:
jobs:
safety:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Install dependencies
- name: Sync dependencies
env:
UV_LINK_MODE: copy
run: uv sync
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
- name: Run safety check
run: uv run safety check
+15 -33
View File
@@ -8,23 +8,17 @@ on:
jobs:
unit-tests:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Install dependencies
- name: Sync dependencies
env:
UV_LINK_MODE: copy
run: uv sync --all-extras
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
- name: Run unit tests
run: |
@@ -43,23 +37,17 @@ jobs:
compression-level: 0
integration-tests:
runs-on: ubuntu-latest
runs-on: python-repositories-ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Install dependencies
- name: Sync dependencies
env:
UV_LINK_MODE: copy
run: uv sync --all-extras
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
- name: Verify Docker
run: docker info
@@ -83,24 +71,18 @@ jobs:
coverage-report:
# Merges unit + integration coverage and enforces fail_under from pyproject.toml.
needs: [unit-tests, integration-tests]
runs-on: ubuntu-latest
runs-on: python-repositories-ci
if: github.event_name == 'pull_request' || github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Install uv
run: pip install uv
- name: Install dependencies
- name: Sync dependencies
env:
UV_LINK_MODE: copy
run: uv sync --all-extras
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
- name: Download unit coverage
uses: https://github.com/christopherHX/gitea-download-artifact@v4
+19 -17
View File
@@ -11,28 +11,30 @@ repos:
- id: name-tests-test
- id: check-merge-conflict
# Python linting and formatting with Ruff
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.8
# Python tooling via uv (versions pinned in uv.lock)
- repo: local
hooks:
- id: ruff-check
args: [--fix]
name: ruff check
entry: uv run ruff check --fix
language: system
types: [python]
- id: ruff-format
# Static type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
name: ruff format
entry: uv run ruff format
language: system
types: [python]
- id: mypy
additional_dependencies:
- types-redis
# Python syntax modernization with pyupgrade
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
name: mypy
entry: uv run mypy .
language: system
types: [python]
pass_filenames: false
- id: pyupgrade
args: ["--py312-plus"]
name: pyupgrade
entry: uv run pyupgrade --py312-plus
language: system
types: [python]
# Formatting for Markdown, JSON, and YAML with Prettier
- repo: https://github.com/pre-commit/mirrors-prettier
+359
View File
@@ -0,0 +1,359 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.1.0] - 2026-07-11
### Summary
Add runtime-checkable Protocol typing to public interfaces
### Changed
- 1cbcc1c Merge pull request '[minor] Add runtime-checkable Protocol typing to public interfaces' (#57) from cursor/protocol-abc-interfaces into main
- a0e5c9d Document test organization and add Cursor workflow rules.
- 44b15cb Move structural typing tests into interface unit test files.
- f6ee9a3 Add runtime-checkable Protocol typing to all public interfaces.
- 0829354 Merge pull request 'Add async future note and remove redundant pytest pythonpath' (#56) from cursor/readme-async-and-test-path-cleanup into main
- a0d509a Format CHANGELOG.md with Prettier.
- 9b7b15a Document possible async support and drop redundant pytest pythonpath.
## [2.0.5] - 2026-07-10
### Summary
Allow empty payloads in Redis and MinIO adapters
### Changed
- 6d02f32 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
- 57b396b Allow empty payloads in Redis and MinIO adapters.
- 093e538 Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
- b28ac6e Scope integration fixtures per backend subdirectory.
- 6c66fe4 Merge pull request 'Add consumer-facing CHANGELOG.md maintained by release CI' (#53) from cursor/consumer-changelog into main
- 089a128 Format CHANGELOG.md with Prettier
- 0a5bd39 Add consumer-facing CHANGELOG.md maintained by release CI.
- 683ee33 Merge pull request '[chore] add repository URL and Python 3.12 packaging metadata' (#52) from cursor/add-packaging-metadata into main
- 9df5032 Add repository URL and Python 3.12 packaging metadata.
- 4f6f33f Merge pull request 'Drop types-redis in favor of redis inline types' (#51) from cursor/drop-types-redis into main
- 10d43ce Drop types-redis in favor of redis inline types.
## [2.0.4] - 2026-07-10
### Summary
Include optional dependencies in security audit
### Changed
- 4f7b672 chore: release v2.0.4 [skip ci]
- a27e9e5 Merge pull request '[fix] Include optional dependencies in security audit' (#50) from cursor/security-audit-all-extras into main
- fd0045d Include optional dependencies in security audit.
- 515f4bb Merge pull request 'revert 707f11f578f007c7152582ea524b66dacea50111' (#49) from brian-patch-1 into main
- ef31fb4 revert 707f11f578f007c7152582ea524b66dacea50111
- 707f11f chore: release v2.0.4 [skip ci]
- b1d34f8 Merge pull request '[patch] Unify dev tooling via uv so pre-commit, CI, and the update bot stay aligned' (#48) from chore/align-dev-tooling-via-uv into main
- 565879d Unify dev tooling via uv so pre-commit, CI, and the update bot stay aligned.
- 0d0f430 Merge pull request 'Fix automated package publishing in release workflow' (#47) from fix/release-workflow-publish into main
- 5ac40b1 Publish packages from the release workflow so automated releases reach the registry.
## [2.0.3] - 2026-07-10
### Summary
Use structured fields for adapter debug and info logs
### Changed
- fa4ff1f chore: release v2.0.3 [skip ci]
- 0a39151 Merge pull request '[patch] Use structured fields for adapter debug and info logs' (#46) from cursor/structured-adapter-logging into main
- 82a702d Fix mypy error in Minio adapter log assertion.
- b886a7c Use structured fields for adapter debug and info logs.
- 4518a93 Merge pull request 'Migrate Python workflows to python-repositories-ci runner' (#41) from cursor/migrate-python-repositories-ci into main
- a8b7b59 Migrate Python workflows to python-repositories-ci runner label.
- 0e6941d Merge pull request 'Use lowercase CI image registry owner for Docker compatibility' (#45) from cursor/fix-ci-registry-owner-lowercase into main
- a6428cc Use lowercase CI image registry owner for Docker compatibility.
- 655fdc4 Merge pull request 'Fix CI image registry owner to match Gitea org slug' (#44) from cursor/fix-ci-registry-owner-case into main
- e031c54 Fix CI image registry owner to match Gitea org slug.
- 49b6238 Merge pull request 'Rename CI registry owner from brian to lille-vemmelund' (#43) from cursor/rename-ci-owner-to-lille-vemmelund into main
- 0549358 Keep python-utils package index under brian owner.
- 33efbd1 Update CI registry owner from brian to lille-vemmelund.
- 24126e4 Merge pull request 'Fix CI image build failures on runners with isolated DinD DNS' (#42) from fix/ci-image-build-dns into main
- f3b6cdb Fix CI image build failures on runners with isolated DinD DNS.
- fa2554e Merge pull request 'Add nightly CI base image and migrate workflows to python-repositories-ci' (#40) from cursor/ci-base-image into main
- b21fd24 Revert workflows to ubuntu-latest until CI image is seeded.
- 441a212 Add local CI image build script and document bootstrap flow.
- f2a8581 Read Python version from .python-version in CI image build.
- 3b05260 Add nightly CI base image and migrate workflows to python-repositories-ci.
## [2.0.2] - 2026-07-09
### Summary
Use dict[str, Any] for JSON repository document types
### Changed
- 369295b chore: release v2.0.2 [skip ci]
- 95c7859 Merge pull request '[patch] Use dict[str, Any] for JSON repository document types' (#38) from cursor/json-dict-str-any-typing into main
- 42e885e Use dict[str, Any] for JSON repository document types.
## [2.0.1] - 2026-07-09
### Summary
Align Redis and MinIO connect() idempotency
### Changed
- bd4794d chore: release v2.0.1 [skip ci]
- 3dc412c Merge pull request '[patch] Align Redis and MinIO connect() idempotency' (#37) from cursor/connect-idempotency-alignment into main
- b8d88e6 Cover injected-client connect when client was cleared by disconnect.
- 67bb88f Align Redis and MinIO connect() idempotency in ConnectionAwareAdapter.
## [2.0.0] - 2026-07-09
### Summary
Make MinIO bucket creation opt-in with production-safe defaults
### Changed
- c2e6a96 chore: release v2.0.0 [skip ci]
- 547a1b3 Merge pull request '[breaking] Make MinIO bucket creation opt-in with production-safe defaults' (#36) from cursor/minio-bucket-creation-opt-in into main
- 39baf9b Fix examples integration test by provisioning MinIO bucket in fixture.
- 4a5b3b6 Make MinIO bucket creation opt-in with production-safe defaults.
- 1431f45 Merge pull request 'Move Redis test container helpers into integration conftest' (#35) from cursor/move-redis-container-to-conftest into main
- eafc717 Move Redis test container helpers into integration conftest.
## [1.1.0] - 2026-07-08
### Summary
Add Redis scan_keys iterator API
### Changed
- 9a9b7b2 chore: release v1.1.0 [skip ci]
- 5b9573d Merge pull request '[minor] Add Redis scan_keys iterator API' (#34) from cursor/redis-scan-keys into main
- 50444af Add Redis scan_keys iterator API.
## [1.0.0] - 2026-07-08
### Summary
Clarify MinIO get() error semantics to match Redis behavior
### Changed
- 2f19fcc chore: release v1.0.0 [skip ci]
- 3bd6589 Merge pull request '[breaking] Clarify MinIO get() error semantics to match Redis behavior' (#33) from cursor/minio-error-semantics into main
- 5311d49 Clarify MinIO get() error semantics to match Redis behavior.
## [0.5.1] - 2026-07-07
### Summary
Fix MinIO get_object response leak in MinioAdapter.get()
### Changed
- 703bb95 chore: release v0.5.1 [skip ci]
- 8e47ebf Merge pull request '[patch] Fix MinIO get_object response leak in MinioAdapter.get()' (#32) from fix/minio-get-object-response-cleanup into main
- 9a800bf Fetch live PR title in pr-title-check CI workflow.
- 8c34534 Fix MinIO get_object response leak in MinioAdapter.get().
- b1210bd Merge pull request 'Reach 100% combined coverage with targeted unit tests' (#31) from cursor/balanced-100-coverage into main
- 39b1043 Fix mypy unreachable-code warning in minio reconnect test.
- beb2b51 Reach 100% combined coverage with targeted unit tests.
- ce062be Merge pull request 'Enforce 90% combined coverage floor in CI' (#29) from cursor/enforce-coverage-floor into main
- a381d45 Lower combined coverage floor from 95% to 90%.
- 5c8cd84 Document where combined coverage floor is enforced in CI.
- 3463298 Skip coverage floor check in partial CI test jobs.
- 5393efc Enforce a 95% combined coverage floor in CI and local runs.
- e39fc96 Merge pull request 'Split CI into parallel unit, integration, and coverage report jobs' (#28) from cursor/split-ci-test-jobs into main
- c1f4826 Use christopherhx v4 artifact actions for parallel coverage merge.
- 0315a33 Avoid cross-job artifacts; combine coverage with --cov-append.
- 5d33ec6 Use artifact action v3 for Gitea Actions compatibility.
- 9538e4d Split CI into parallel unit, integration, and coverage report jobs.
- 14b8d27 Merge pull request 'Replace hardcoded version examples in release docs' (#27) from cursor/fix-version-doc-drift into main
- d3cbb65 Use version-agnostic examples in release docs.
## [0.5.0] - 2026-07-06
### Summary
Add config and client injection with test reorganization
### Changed
- 7dbb1fc chore: release v0.5.0 [skip ci]
- 918e5e6 Merge pull request '[minor] Add config and client injection with test reorganization' (#25) from cursor/config-client-injection into main
- 3aa9128 Load MINIO_SECURE from env and replace adapter asserts with explicit errors.
- 7991dab Add config and client injection with test reorganization.
- 366831a Merge pull request 'chore(deps): update dependencies' (#26) from renovate/auto-deps-update into main
- a98905d chore(deps): update dependencies [automated]
- 7ed1b34 Merge pull request 'Sync uv.lock on release to prevent CI failures' (#24) from cursor/sync-uv-lock-on-release into main
- 3236f27 manually updated package version
- ac487d9 Sync uv.lock on release to keep CI clean.
## [0.4.1] - 2026-07-05
### Summary
Extract shared connection lifecycle into ConnectionAwareAdapter
### Changed
- a049ce3 chore: release v0.4.1 [skip ci]
- 7b4cf84 Merge pull request '[patch] Extract shared connection lifecycle into ConnectionAwareAdapter' (#23) from cursor/extract-connection-aware-adapter into main
- b5a92e6 Sync uv.lock with pyproject.toml version 0.4.0.
- 7b950e0 Extract shared connection lifecycle into ConnectionAwareAdapter.
## [0.4.0] - 2026-07-05
### Summary
Strengthen is_connected with cached health probes
### Changed
- fe0c477 chore: release v0.4.0 [skip ci]
- e7e0fc8 Merge pull request '[minor] Strengthen is_connected with cached health probes' (#22) from cursor/strengthen-is-connected-health-checks into main
- 34fc046 Sync uv.lock with pyproject.toml version 0.3.2.
- 5149299 Strengthen is_connected with cached health probes.
## [0.3.2] - 2026-06-30
### Summary
Fix optional dependency handling for Redis and MinIO adapters
### Changed
- fef9552 chore: release v0.3.2 [skip ci]
- e9b2189 Merge pull request '[patch] Fix optional dependency handling for Redis and MinIO adapters' (#19) from cursor/fix-optional-dependencies into main
- f595390 Fix optional-deps import test for full-suite execution.
- 1f02195 Delegate top-level adapter imports to adapters package.
- f092ca2 Fix trailing whitespace in LICENSE and .gitignore.
- c6eed43 Fix optional dependency handling for Redis and MinIO adapters.
- 9e4b1e2 Merge pull request 'Avoid testcontainers Redis deprecation warning in integration tests.' (#18) from fix/redis-testcontainer-deprecation into main
- 0096df7 Avoid testcontainers Redis deprecation warning in integration tests.
- 3073bfb Merge pull request 'Fix PR coverage comment posting in CI.' (#17) from fix/coverage-pr-comment into main
- 30a1de3 Fix PR coverage comment posting in CI.
- 8465957 Merge pull request '[patch] update dependencies' (#16) from renovate/auto-deps-update into main
- 3ecb9c3 chore: retrigger CI
- 0f31170 Fix mypy errors from minio 7.2.20 type stubs.
- e3cab5e chore(deps): update dependencies [automated]
- f428a97 Merge pull request 'Fix dependency bot PR creation for Gitea.' (#15) from cursor/fix-dependency-bot-gitea-pr into main
- a3b9912 Fix dependency bot PR creation for Gitea.
- abaa078 Merge pull request 'Fix dependency update bot to use uv lock --upgrade.' (#14) from cursor/fix-dependency-update-bot-uv-lock into main
- 9864f0c Fix dependency update bot to use uv lock --upgrade.
- e39b467 Merge pull request 'Automate releases from PR titles and clean up CI workflows.' (#13) from cursor/pr-title-release-automation into main
- 85c1a64 Sync uv.lock with pyproject.toml version 0.3.1.
- a8491c0 Automate releases from PR titles and clean up CI workflows.
## [0.3.1] - 2026-06-28
### Summary
Add public repository interfaces and subclassable adapter CRUD API.
### Changed
- 2bb361e Merge pull request 'Add public repository interfaces and subclassable adapter CRUD API.' (#11) from cursor/public-repository-api into main
- c5169df Use pre-commit for Prettier in CI and document local hook setup.
- 497533f Fix mypy context manager typing for adapter subclasses.
- e98fd3b Add public repository interfaces and subclassable adapter CRUD API.
- f4280f1 Merge pull request 'Remove non-existent Mongo adapter from README.' (#10) from cleanup/remove-mongo-adapter-mentions into main
- d9b9fa4 updated package settings
- 9d25f7c updated package settings
- 1635815 updated package settings
- 933c4ee updated package settings
- 2b0d98f updated package settings
- 729fa48 Remove non-existent Mongo adapter from README.
## [0.2.3] - 2025-09-18
### Summary
added support for content type when putting object in minio
### Changed
- fa011be Merge pull request 'added support for content type when putting object in minio' (#9) from add-support-for-content-type-in-minio into main
- 2e93309 ruff format fix
- 3ab8da9 added support for content type when putting object in minio
## [0.2.2] - 2025-09-17
### Summary
moved import of optional packages into classes
### Changed
- 1dd604d Merge pull request 'moved import of optional packages into classes' (#6) from make-optional-parts-independent into main
- e5c4025 added graceful handling of missing optional packages
- 396cf83 code quality fixes
- 538b26b mypy fixed
- ee6acc6 moved import of optional packages into classes
## [0.2.1] - 2025-09-15
### Summary
exposed minio adapter
### Changed
- 0ca07c0 Merge pull request 'exposed minio adapter' (#5) from expose-minio-adapter into main
- 7446e88 added py.typed mypy config file
- d5f437f ruff format fix
- 7beca7c exposed minio adapter
## [0.2.0] - 2025-09-15
### Summary
add-minio-adapter
### Changed
- 65d8051 Merge pull request 'add-minio-adapter' (#4) from add-minio-adapter into main
- f5af303 auto install all extra packages
- 3260a3f added new optional package to ci
- c7bced4 ruff lint fixes
- 800d702 pyupgrade
- 5a72507 ruff format
- c45ba3c mypy fixes
- a3b5443 added minio adapter and tests
- cc99fe5 minor corrections
- 93fffe1 added optional group minio
## [0.1.0] - 2025-09-14
### Summary
Release v0.1.0
### Changed
- 2eafd73 Merge pull request 'added redis adapter' (#1) from collect-code-from-other-repos into main
- 1cc047d removed unused bound ports for test
- c7cc2a3 added optional package redis
- b5d0297 pyupgrade fixed imports
- 2fa633a ruff fixed formatting
- 0e47db2 fuxed mypy issues
- 0948799 added integration tests
- 524bd0f fixed connection error handling
- 969c2c8 installed more pytest-cov
- 7caee87 added installing optional redis group
- 918535e added test coverage and upload step
- 5d7afe7 ruff format corrections
- 59a943e added redis types stub and fixed return type
- 1abcae4 added redis adapter
- c789e33 initial commit
- 56cad42 Initial commit
+36 -7
View File
@@ -12,8 +12,14 @@ Subclass an adapter in your own repository to add domain-specific methods while
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
| **Your project** | Subclass an adapter and add domain methods |
Each public interface is a `@runtime_checkable` `Protocol` with `@abstractmethod` members. **Subclass an adapter** when you need connection management and shared behavior — explicit subclasses get runtime instantiation guards and inherited default methods (e.g. `scan_keys`). **Type-annotate against an interface** when you want loose coupling — any object with the right methods satisfies the contract for mypy and `isinstance()` checks, without inheriting from this package.
Connection adapters expose `connect()`, `disconnect()`, and `is_connected()`. The latter verifies backend reachability with a cached health probe (default TTL: 1 second). Subclasses may override `health_check_ttl_seconds`. `connect()` is idempotent: calling it while already connected and healthy is a no-op.
## Future direction
The current API is synchronous. Async repository interfaces and adapters may be added in a future release; existing sync usage would remain supported.
## Optional dependencies
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
@@ -123,16 +129,18 @@ with ArtifactObjectRepository() as repo:
### Subclassing in your own project
```python
from typing import Any
from python_repositories import RedisAdapter
class UserRepository(RedisAdapter):
def _key(self, user_id: str) -> str:
return f"user:{user_id}"
def get_user(self, user_id: str) -> dict | None:
def get_user(self, user_id: str) -> dict[str, Any] | None:
return self.get(self._key(user_id))
def save_user(self, user_id: str, user: dict) -> None:
def save_user(self, user_id: str, user: dict[str, Any]) -> None:
self.set(self._key(user_id), user)
```
@@ -159,12 +167,30 @@ uv sync --all-extras
uv run pre-commit install # once per clone — runs hooks on git commit
uv run pytest tests/unit/ -v # fast, no Docker
uv run pytest -m "not integration" -v # all non-Docker tests
uv run pytest tests/integration/redis/ -v # Redis container only
uv run pytest tests/integration/minio/ -v # MinIO container only
uv run pytest -v # full suite (requires Docker)
```
Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Run unit tests alone for quick local feedback.
Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Backend-specific markers (`needs_redis`, `needs_minio`) let you run only the containers a test module needs. Run unit tests alone for quick local feedback.
CI runs unit and integration tests in parallel with coverage, then merges `.coverage` artifacts in a follow-up job (via [christopherhx/gitea-\*-artifact@v4](https://github.com/christopherHX/gitea-upload-artifact) for Gitea 1.26 compatibility). Combined coverage must be at least **90%**; the floor is set by [`fail_under` in `pyproject.toml`](pyproject.toml#L45-L48) and enforced after merging unit and integration coverage, not on unit-only runs.
### Test organization
Unit tests live in `tests/unit/` and follow a one-to-one naming convention: `<module>_test.py` tests `python_repositories/<module path>.py`. Examples:
- `json_repository_interface.py``tests/unit/json_repository_interface_test.py`
- `redis_adapter.py``tests/unit/redis_adapter_test.py`
Add new tests to the existing file for that module rather than creating cross-cutting test files. Shared fixtures belong in `tests/conftest.py`; module-specific helpers may live in the matching test file.
### CI base image
Gitea Actions jobs use a pre-built image (`python-repositories-ci`) with Python 3.12,
`uv`, and locked dependencies baked in. The image is rebuilt nightly and when
`uv.lock` changes; see [`docs/ci-image.md`](docs/ci-image.md) for runner setup and
bootstrap order.
CI runs unit and integration tests in parallel with coverage, then merges `.coverage` artifacts in a follow-up job (via [christopherhx/gitea-\*-artifact@v4](https://github.com/christopherHX/gitea-upload-artifact) for Gitea 1.26 compatibility). Combined coverage must meet the floor in [`fail_under` in `pyproject.toml`](pyproject.toml#L45-L48); enforcement happens after merging unit and integration coverage, not on unit-only runs.
To check coverage locally (requires Docker for the full suite):
@@ -185,12 +211,15 @@ uv run pre-commit run --all-files
Releases are automated when a pull request is merged to `main`. CI reads the **merged PR title** to decide whether and how to bump the version.
- **Version history:** [`CHANGELOG.md`](CHANGELOG.md)
- **Gitea releases:** [releases page](https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/releases)
### How it works
1. Open a PR targeting `main` (see [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
2. If the PR changes files under `python_repositories/`, the title **must** start with a version bump prefix (enforced by CI).
3. On merge, [`release.yml`](.gitea/workflows/release.yml) bumps [`pyproject.toml`](pyproject.toml) and syncs [`uv.lock`](uv.lock), commits, tags `vX.Y.Z`, creates a Gitea release with auto-generated notes, and pushes the tag.
4. [`publish.yml`](.gitea/workflows/publish.yml) builds and publishes the package to the Gitea Package Registry.
3. On merge, [`release.yml`](.gitea/workflows/release.yml) bumps [`pyproject.toml`](pyproject.toml) and syncs [`uv.lock`](uv.lock), updates [`CHANGELOG.md`](CHANGELOG.md), commits, tags `vX.Y.Z`, creates a Gitea release with auto-generated notes, pushes the tag, and publishes the package to the Gitea Package Registry.
4. [`publish.yml`](.gitea/workflows/publish.yml) handles manual `v*.*.*` tag pushes only (automated releases publish from `release.yml` because release commits use `[skip ci]`, which suppresses tag-push workflows).
Docs-, CI-, and test-only PRs do not need a prefix and will not trigger a release.
@@ -210,7 +239,7 @@ Example titles:
### Release notes
Release notes are generated from commits since the previous tag (see [`scripts/ci/generate-release-notes.sh`](scripts/ci/generate-release-notes.sh)).
Release notes are generated from commits since the previous tag (see [`scripts/ci/generate-release-notes.sh`](scripts/ci/generate-release-notes.sh)). The same content is appended to [`CHANGELOG.md`](CHANGELOG.md) on each release (see [`scripts/ci/update-changelog.sh`](scripts/ci/update-changelog.sh)).
### Manual release
+29
View File
@@ -0,0 +1,29 @@
# CI job image for Gitea Actions (act_runner requires Node.js in job containers).
FROM node:20-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=ghcr.io/astral-sh/uv:0.7.0 /uv /usr/local/bin/uv
ENV UV_PYTHON_INSTALL_DIR=/opt/uv-python \
UV_LINK_MODE=copy
COPY .python-version /tmp/.python-version
RUN uv python install "$(cat /tmp/.python-version)"
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
ENV PATH="/app/.venv/bin:${PATH}"
RUN --mount=type=secret,id=uv_token \
UV_INDEX_GITEA_USERNAME=ci-bot \
UV_INDEX_GITEA_PASSWORD="$(cat /run/secrets/uv_token)" \
uv sync --all-extras --frozen --no-install-project
+169
View File
@@ -0,0 +1,169 @@
# CI base image
This repository uses a **per-repo Docker image** for Gitea Actions jobs instead of
installing Python and `uv` on every run. Infrastructure images (redis, minio, etc.)
are cached cluster-wide via Harbor (see homelab-platform
[`docs/harbor-registry-mirror.md`](https://gitea.lille-vemmelund.dk/LilleVemmelund/homelab-platform/src/branch/main/docs/harbor-registry-mirror.md)).
## Image contents
Built from [`docker/ci/Dockerfile`](../docker/ci/Dockerfile):
- Node.js 20 (required by act_runner job containers)
- Python 3.12 (installed via `uv python install` from [`.python-version`](../.python-version)) and pinned `uv` 0.7.0
- Docker CLI (integration tests via testcontainers)
- Dev dependencies from `uv.lock` (`uv sync --all-extras --no-install-project`)
Published to the Gitea container registry (owner segment must be lowercase for Docker):
- `gitea.lille-vemmelund.dk/lillevemmelund/python-repositories-ci:latest`
- `gitea.lille-vemmelund.dk/lillevemmelund/python-repositories-ci:YYYYMMDDHHmm` (timestamped rollback tag)
## Rebuild triggers
[`ci-image.yml`](../.gitea/workflows/ci-image.yml) runs on:
- Nightly cron (`0 2 * * *` UTC)
- Manual `workflow_dispatch`
- Push to `main` when `pyproject.toml`, `uv.lock`, or `docker/ci/**` change
## Workflow usage
Python jobs use `runs-on: python-repositories-ci` and a fast incremental sync:
```yaml
runs-on: python-repositories-ci
steps:
- uses: actions/checkout@v4
- name: Sync dependencies
env:
UV_LINK_MODE: copy
UV_INDEX_GITEA_USERNAME: ci-bot
UV_INDEX_GITEA_PASSWORD: ${{ secrets.CI_RUNNER_TOKEN }}
run: uv sync --all-extras --frozen
```
`ci-image.yml` uses `runs-on: ubuntu-latest` so it can bootstrap before the custom
image exists.
## Registry authentication
act_runner pulls the job image **before any workflow step runs**, so a `docker
login` step inside a job cannot authenticate that pull. Authentication must be
configured on the runner (or via a job-level `container.credentials` block — not
used here).
### k8s Gitea runners (automatic)
The homelab-platform runners mount Gitea registry credentials automatically:
- Secret: `gitea-runners/gitea-registry-dockerconfig` (created by
[`scripts/create-gitea-registry-secret.sh`](https://gitea.lille-vemmelund.dk/LilleVemmelund/homelab-platform/src/branch/main/scripts/create-gitea-registry-secret.sh))
- Mounted at `/root/.docker/config.json` on the **runner** container (not DinD)
- Configured in
[`platform/gitea-runners/values.yaml`](https://gitea.lille-vemmelund.dk/LilleVemmelund/homelab-platform/src/branch/main/platform/gitea-runners/values.yaml)
No manual `docker login` is required on the in-cluster runners once the secret
exists. To create or rotate credentials:
```bash
export KUBECONFIG=/path/to/homelab-cluster/talos/_out/kubeconfig
export GITEA_REGISTRY_USERNAME='ci-bot'
export GITEA_REGISTRY_PASSWORD='personal-access-token-with-read-package'
bash scripts/create-gitea-registry-secret.sh
```
Then restart runner pods so they pick up the updated secret. See homelab-platform
[`docs/gitea-actions-runners.md`](https://gitea.lille-vemmelund.dk/LilleVemmelund/homelab-platform/src/branch/main/docs/gitea-actions-runners.md)
for full runner setup.
Verify on a running pod:
```bash
kubectl -n gitea-runners exec homelab-cluster-gitea-runner-0 -c runner -- \
test -f /root/.docker/config.json && echo "registry auth mounted"
```
### Standalone runners (e.g. Unraid `homelab`)
The 4th runner is outside the k8s cluster and does **not** get the automatic
mount. Configure registry auth manually on that host:
```bash
docker login gitea.lille-vemmelund.dk -u ci-bot -p <token>
```
Also add the `python-repositories-ci` label to that runner's act_runner config.
### `ci-image.yml` push login
The build workflow still runs `docker login` before `docker push`. That step
authenticates **DinD inside the job** for pushing the image to Gitea — a different
code path from act_runner pulling the job container.
## Runner label
Jobs use the `python-repositories-ci` act_runner label, configured in
homelab-platform
[`platform/gitea-runners/values.yaml`](https://gitea.lille-vemmelund.dk/LilleVemmelund/homelab-platform/src/branch/main/platform/gitea-runners/values.yaml):
```yaml
python-repositories-ci:docker://gitea.lille-vemmelund.dk/lillevemmelund/python-repositories-ci:latest
```
After label changes, roll runner pods so they re-register with Gitea.
## Bootstrap order
1. Ensure homelab-platform runners have `gitea-registry-dockerconfig` and the
`python-repositories-ci` label (see homelab-platform docs).
2. Merge `ci-image.yml`, `docker/ci/Dockerfile`, and workflow migrations to `main`.
3. Seed the registry with a first image (see below).
4. Confirm a test workflow job starts on `python-repositories-ci`.
Until step 3 completes, jobs targeting `python-repositories-ci` will fail because
the image does not exist in the Gitea registry yet.
### Seed the image
**After merge to `main`:** Actions → **Build CI Image****Run workflow**.
**Before merge (e.g. PR branch):** the workflow file is not on `main` yet — build
and push locally with [`scripts/ci/build-ci-image.sh`](../scripts/ci/build-ci-image.sh):
```bash
export CI_RUNNER_TOKEN='ci-bot-personal-access-token'
bash scripts/ci/build-ci-image.sh --push
```
Run from the repo root on the branch you want to test. Runners pull
`gitea.lille-vemmelund.dk/lillevemmelund/python-repositories-ci:latest` from the registry;
they do not care which git branch built it.
Override registry settings if needed:
```bash
export REGISTRY=gitea.lille-vemmelund.dk
export REGISTRY_USER=ci-bot
export CI_RUNNER_TOKEN='...'
bash scripts/ci/build-ci-image.sh --push
```
## Local build
Build only (no registry login or push):
```bash
export CI_RUNNER_TOKEN='ci-bot-personal-access-token'
bash scripts/ci/build-ci-image.sh --local
```
Or manually:
```bash
echo "$CI_RUNNER_TOKEN" > /tmp/uv_token
docker build -f docker/ci/Dockerfile \
--secret id=uv_token,src=/tmp/uv_token \
-t python-repositories-ci:local .
rm -f /tmp/uv_token
```
+25 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "python-repositories"
version = "2.0.1"
version = "2.1.0"
description = "Various python repository interfaces exposed as a python package."
authors = [
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
license = { text = "MIT" }
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
@@ -19,6 +20,11 @@ dependencies = [
"structlog>=25.4.0",
]
[project.urls]
Repository = "https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories"
Changelog = "https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/src/branch/main/CHANGELOG.md"
Releases = "https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/releases"
[project.optional-dependencies]
redis = [
"redis>=6.4.0",
@@ -33,10 +39,11 @@ build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--import-mode=importlib"
markers = [
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
"needs_redis: integration test requiring a Redis container",
"needs_minio: integration test requiring a MinIO container",
]
[tool.coverage.run]
@@ -60,6 +67,18 @@ name = "gitea"
url = "https://gitea.lille-vemmelund.dk/api/packages/brian/pypi/simple/"
explicit = true
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
extend-select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["python_repositories", "tests"]
combine-as-imports = true
force-sort-within-sections = true
[tool.mypy]
python_version = "3.12"
warn_return_any = true # nudge to use stricter types
@@ -87,13 +106,12 @@ ignore_missing_imports = true
[dependency-groups]
dev = [
"mypy>=1.17.1",
"pre-commit>=4.3.0",
"mypy>=2.1.0",
"pre-commit>=4.6.0",
"pytest>=8.4.1",
"pytest-cov>=7.0.0",
"pyupgrade>=3.20.0",
"ruff>=0.12.11",
"pyupgrade>=3.21.2",
"ruff>=0.15.20",
"safety>=3.6.0",
"testcontainers>=4.13.0",
"types-redis>=4.6.0.20241004",
]
@@ -2,8 +2,8 @@
from __future__ import annotations
import time
from abc import abstractmethod
import time
from typing import Self
import structlog
@@ -73,7 +73,7 @@ class ConnectionAwareAdapter(ConnectionAwareInterface, ContextAwareInterface):
self._invalidate_health_cache()
return
if self._is_client_ready() and self.is_connected():
self.logger.info(f"Already connected to {self.connection_name}")
self.logger.info("Already connected", connection_name=self.connection_name)
return
self.disconnect()
self._establish_connection()
+27 -11
View File
@@ -1,6 +1,7 @@
"""Definition of MinioAdapter class."""
from __future__ import annotations
from io import BytesIO
from python_repositories.adapters.connection_aware_adapter import (
@@ -18,7 +19,7 @@ except ImportError as exc:
) from exc
class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
class MinioAdapter(ConnectionAwareAdapter, ObjectRepositoryInterface):
"""Minio adapter exposing basic CRUD functionality."""
endpoint_env_var_name: str = "MINIO_ENDPOINT"
@@ -88,7 +89,7 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
raise ConnectionError(
f"Bucket '{bucket}' does not exist on Minio at {endpoint}"
)
self.logger.info(f"Creating bucket '{bucket}'")
self.logger.info("Creating bucket", bucket=bucket)
client.make_bucket(bucket)
self._client = client
self._bucket_name = bucket
@@ -113,12 +114,17 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
"""Put an object into the Minio bucket."""
"""Put an object into the Minio bucket.
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
an object entirely.
"""
# Check input
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
if not isinstance(data, BytesIO) or data.getbuffer().nbytes == 0:
raise ValueError("data must be a non-empty BytesIO object")
if not isinstance(data, BytesIO):
raise ValueError("data must be a BytesIO object")
if not isinstance(content_type, str) or len(content_type) == 0:
raise ValueError("content_type must be a non-empty string")
# Check connection
@@ -138,11 +144,16 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
content_type=content_type,
)
self.logger.debug(
f"Put object '{object_name}' into bucket '{self._bucket_name}'"
"Put object", object_name=object_name, bucket=self._bucket_name
)
def get(self, object_name: str) -> BytesIO | None:
"""Get an object from the Minio bucket."""
"""Get an object from the Minio bucket.
Returns ``None`` when the object does not exist. Returns an empty
``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
existence.
"""
# Check input
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
@@ -163,13 +174,15 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
buffer.write(chunk)
buffer.seek(0)
self.logger.debug(
f"Got object '{object_name}' from bucket '{self._bucket_name}'"
"Got object", object_name=object_name, bucket=self._bucket_name
)
return buffer
except minio.S3Error as exc:
if exc.code == "NoSuchKey":
self.logger.warning(
f"Object '{object_name}' not found in bucket '{self._bucket_name}'"
"Object not found",
object_name=object_name,
bucket=self._bucket_name,
)
return None
raise
@@ -193,7 +206,7 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
object_name=object_name,
)
self.logger.debug(
f"Deleted object '{object_name}' from bucket '{self._bucket_name}'"
"Deleted object", object_name=object_name, bucket=self._bucket_name
)
def list_objects(self, prefix: str = "") -> list[str]:
@@ -215,6 +228,9 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
obj.object_name for obj in objects if obj.object_name is not None
]
self.logger.debug(
f"Listed {len(object_names)} object(s) in bucket '{self._bucket_name}' with prefix '{prefix}'"
"Listed objects",
count=len(object_names),
bucket=self._bucket_name,
prefix=prefix,
)
return object_names
+24 -13
View File
@@ -1,8 +1,9 @@
"""Definition of RedisAdapter class."""
from __future__ import annotations
from collections.abc import Iterator
from typing import cast
from typing import Any, cast
from python_repositories.adapters.connection_aware_adapter import (
ConnectionAwareAdapter,
@@ -20,7 +21,7 @@ except ImportError as exc:
) from exc
class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
class RedisAdapter(ConnectionAwareAdapter, JsonRepositoryInterface):
"""Redis adapter exposing basic CRUD functionality."""
uri_env_var_name: str = "REDIS_URI"
@@ -89,22 +90,32 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
except (redis.ConnectionError, redis.TimeoutError):
return False
def set(self, key: str, data: dict) -> None:
"""Set a JSON object in Redis."""
def set(self, key: str, data: dict[str, Any]) -> None:
"""Set a JSON object in Redis.
Accepts any dict, including ``{}``. An empty dict creates a key that
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
key entirely.
"""
# Check input
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
if not isinstance(data, dict) or len(data) == 0:
raise ValueError("Data must be a non-empty dictionary")
if not isinstance(data, dict):
raise ValueError("Data must be a dictionary")
# Check connection
self._require_connected()
assert self._client is not None
# Set data
self._client.json().set(key, self.path, data)
self.logger.debug(f"Set {key} to {data}")
self.logger.debug("Set key", key=key, data_keys=list(data.keys()))
def get(self, key: str) -> dict | None:
"""Get a JSON object from Redis."""
def get(self, key: str) -> dict[str, Any] | None:
"""Get a JSON object from Redis.
Returns ``None`` if the key is absent. Returns ``{}`` if the key exists
with an empty JSON object. Use ``value is not None`` to test existence;
avoid truthiness checks (``{}`` is falsy).
"""
# Check input
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
@@ -113,10 +124,10 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
assert self._client is not None
# Get data
data = cast(
dict | None,
dict[str, Any] | None,
self._client.json().get(key),
)
self.logger.debug(f"Got {data} from {key}")
self.logger.debug("Got value", key=key, found=data is not None)
return data
def delete(self, key: str) -> None:
@@ -129,7 +140,7 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
assert self._client is not None
# Delete data
self._client.json().delete(key)
self.logger.debug(f"Deleted {key}")
self.logger.debug("Deleted key", key=key)
def _validate_pattern(self, pattern: str) -> None:
if not isinstance(pattern, str) or len(pattern) == 0:
@@ -147,7 +158,7 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
self._client.keys(pattern),
)
keys: list[str] = [key.decode(self.encoding) for key in keys_raw]
self.logger.debug(f"Got {keys} matching {pattern}")
self.logger.debug("Listed keys", pattern=pattern, count=len(keys))
return keys
def scan_keys(
+1 -1
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
import os
from dataclasses import dataclass
import os
from python_utils import check_env
+1 -1
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
import os
from dataclasses import dataclass
import os
from python_utils import check_env
@@ -1,5 +1,7 @@
"""Example domain repository backed by Redis JSON."""
from typing import Any
from python_repositories.adapters.redis_adapter import RedisAdapter
@@ -9,10 +11,10 @@ class UserJsonRepository(RedisAdapter):
def _key(self, user_id: str) -> str:
return f"user:{user_id}"
def get_user(self, user_id: str) -> dict | None:
def get_user(self, user_id: str) -> dict[str, Any] | None:
return self.get(self._key(user_id))
def save_user(self, user_id: str, user: dict) -> None:
def save_user(self, user_id: str, user: dict[str, Any]) -> None:
self.set(self._key(user_id), user)
def delete_user(self, user_id: str) -> None:
@@ -1,9 +1,11 @@
"""Definition of ConnectionAwareInterface abstract base class."""
"""Definition of ConnectionAwareInterface protocol and abstract base class."""
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Protocol, runtime_checkable
class ConnectionAwareInterface(ABC):
@runtime_checkable
class ConnectionAwareInterface(Protocol):
"""Interface that defines connection-related methods."""
@abstractmethod
@@ -1,12 +1,13 @@
"""Definition of ContextAwareInterface abstract base class."""
"""Definition of ContextAwareInterface protocol and abstract base class."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Self
from abc import abstractmethod
from typing import Protocol, Self, runtime_checkable
class ContextAwareInterface(ABC):
@runtime_checkable
class ContextAwareInterface(Protocol):
"""Interface that defines context-related methods."""
@abstractmethod
@@ -1,25 +1,37 @@
"""Definition of JsonRepositoryInterface abstract base class."""
"""Definition of JsonRepositoryInterface protocol and abstract base class."""
from abc import abstractmethod
from collections.abc import Iterator
from abc import ABC, abstractmethod
from typing import Any, Protocol, runtime_checkable
class JsonRepositoryInterface(ABC):
@runtime_checkable
class JsonRepositoryInterface(Protocol):
"""Interface that defines JSON document CRUD methods."""
@abstractmethod
def get(self, key: str) -> dict | None:
"""Get a JSON object by key."""
def get(self, key: str) -> dict[str, Any] | None:
"""Get a JSON object by key.
Returns ``None`` when the key is absent. Returns ``{}`` when the key
exists with an empty JSON object. Use ``value is not None`` to test
existence; avoid truthiness checks (``{}`` is falsy).
"""
...
@abstractmethod
def set(self, key: str, data: dict) -> None:
"""Set a JSON object by key."""
def set(self, key: str, data: dict[str, Any]) -> None:
"""Set a JSON object by key.
Accepts any dict, including ``{}``. An empty dict creates a key that
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
key entirely.
"""
...
@abstractmethod
def delete(self, key: str) -> None:
"""Delete a JSON object by key."""
"""Delete a JSON object by key, removing it entirely."""
...
@abstractmethod
@@ -1,18 +1,22 @@
"""Definition of ObjectRepositoryInterface abstract base class."""
"""Definition of ObjectRepositoryInterface protocol and abstract base class."""
from abc import ABC, abstractmethod
from abc import abstractmethod
from io import BytesIO
from typing import Protocol, runtime_checkable
class ObjectRepositoryInterface(ABC):
@runtime_checkable
class ObjectRepositoryInterface(Protocol):
"""Interface that defines binary object CRUD methods."""
@abstractmethod
def get(self, object_name: str) -> BytesIO | None:
"""Get an object by name.
Returns None when the object does not exist. Raises ConnectionError when
not connected. Other backend errors propagate to the caller.
Returns ``None`` when the object does not exist. Returns an empty
``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
existence. Raises ConnectionError when not connected. Other backend
errors propagate to the caller.
"""
...
@@ -23,12 +27,17 @@ class ObjectRepositoryInterface(ABC):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
"""Put an object by name."""
"""Put an object by name.
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
an object entirely.
"""
...
@abstractmethod
def delete(self, object_name: str) -> None:
"""Delete an object by name."""
"""Delete an object by name, removing it entirely."""
...
@abstractmethod
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
CHANGELOG="CHANGELOG.md"
# Best-effort summary for historical releases without stored PR titles.
summary_for_range() {
local from_ref="$1"
local to_ref="$2"
local subject
while IFS= read -r subject; do
[[ -z "$subject" ]] && continue
if echo "$subject" | grep -qE '^chore: release v'; then
continue
fi
if echo "$subject" | grep -qE "^Merge pull request '"; then
local title
title=$(extract_pr_title_from_merge_commit "$subject")
if [[ -n "$title" ]]; then
strip_bump_prefix "$title"
return 0
fi
fi
echo "$subject"
return 0
done < <(git log "${from_ref}..${to_ref}" --pretty=format:'%s' 2>/dev/null || true)
echo "Release ${to_ref}"
}
TAGS=()
while IFS= read -r tag; do
TAGS+=("$tag")
done < <(git tag -l 'v*' --sort=version:refname)
if [[ ${#TAGS[@]} -eq 0 ]]; then
echo "No version tags found." >&2
exit 1
fi
changelog_header > "$CHANGELOG"
for ((i = ${#TAGS[@]} - 1; i >= 0; i--)); do
TAG="${TAGS[$i]}"
VERSION="${TAG#v}"
DATE=$(git log -1 --format=%cs "$TAG" 2>/dev/null || date -u +%Y-%m-%d)
if [[ $i -eq 0 ]]; then
PREV_TAG=""
else
PREV_TAG="${TAGS[$((i - 1))]}"
fi
SUMMARY=$(summary_for_range "$PREV_TAG" "$TAG")
COMMITS=$(commits_between "$PREV_TAG" "$TAG")
SECTION=$(changelog_section "$VERSION" "$DATE" "$SUMMARY" "$COMMITS")
printf '\n%s\n' "$SECTION" >> "$CHANGELOG"
done
echo "Generated ${CHANGELOG} from ${#TAGS[@]} tags." >&2
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Build and optionally push the Gitea Actions CI base image locally.
#
# Use when ci-image.yml is not yet on main (e.g. bootstrapping a PR branch) or
# when you need to rebuild without waiting for the nightly workflow.
#
# Build only (smoke test):
# export CI_RUNNER_TOKEN='ci-bot-personal-access-token'
# bash scripts/ci/build-ci-image.sh --local
#
# Build and push to Gitea (unblocks python-repositories-ci jobs):
# export CI_RUNNER_TOKEN='ci-bot-personal-access-token'
# bash scripts/ci/build-ci-image.sh --push
#
# Optional overrides:
# REGISTRY=gitea.lille-vemmelund.dk
# REGISTRY_USER=ci-bot
# IMAGE_OWNER=lillevemmelund
# IMAGE_NAME=python-repositories-ci
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
REGISTRY="${REGISTRY:-gitea.lille-vemmelund.dk}"
REGISTRY_USER="${REGISTRY_USER:-ci-bot}"
IMAGE_OWNER="${IMAGE_OWNER:-lillevemmelund}"
IMAGE_NAME="${IMAGE_NAME:-python-repositories-ci}"
IMAGE="${IMAGE:-${REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}}"
MODE="push"
if [[ "${1:-}" == "--local" ]]; then
MODE="local"
elif [[ "${1:-}" == "--push" || -z "${1:-}" ]]; then
MODE="push"
elif [[ -n "${1:-}" ]]; then
echo "Usage: build-ci-image.sh [--local | --push]" >&2
exit 1
fi
: "${CI_RUNNER_TOKEN:?CI_RUNNER_TOKEN is required (ci-bot token with read/write package access)}"
TOKEN_FILE="$(mktemp)"
cleanup() {
rm -f "$TOKEN_FILE"
}
trap cleanup EXIT
printf '%s' "$CI_RUNNER_TOKEN" >"$TOKEN_FILE"
cd "$REPO_ROOT"
if [[ "$MODE" == "local" ]]; then
echo "=== Building local CI image (no push): python-repositories-ci:local ==="
docker build --network=host -f docker/ci/Dockerfile \
--secret "id=uv_token,src=${TOKEN_FILE}" \
-t python-repositories-ci:local \
.
echo "Built python-repositories-ci:local"
exit 0
fi
echo "=== Logging in to ${REGISTRY} as ${REGISTRY_USER} ==="
echo "$CI_RUNNER_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "=== Building ${IMAGE}:latest ==="
docker build --network=host -f docker/ci/Dockerfile \
--secret "id=uv_token,src=${TOKEN_FILE}" \
-t "${IMAGE}:latest" \
.
STAMP="$(date -u +%Y%m%d%H%M)"
echo "=== Pushing ${IMAGE}:latest and ${IMAGE}:${STAMP} ==="
docker tag "${IMAGE}:latest" "${IMAGE}:${STAMP}"
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${STAMP}"
echo "Done. Re-run failing CI jobs — runners pull ${IMAGE}:latest"
+53
View File
@@ -61,6 +61,59 @@ set_pyproject_version() {
rm -f pyproject.toml.bak
}
# Returns markdown bullet list of commits between two refs (exclusive..inclusive).
# Optional third argument limits entries when from_ref is empty (release notes only).
commits_between() {
local from_ref="$1"
local to_ref="${2:-HEAD}"
local limit="${3:-}"
if [[ -z "$from_ref" ]]; then
if [[ -n "$limit" ]]; then
git log "${to_ref}" --pretty=format:'- %h %s' -"${limit}" 2>/dev/null || true
else
git log "${to_ref}" --pretty=format:'- %h %s' 2>/dev/null || true
fi
else
git log "${from_ref}..${to_ref}" --pretty=format:'- %h %s' 2>/dev/null || true
fi
}
commits_since() {
commits_between "$1" HEAD
}
changelog_header() {
cat <<'EOF'
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
EOF
}
changelog_section() {
local version="$1"
local date="$2"
local summary="$3"
local commits="$4"
cat <<EOF
## [${version}] - ${date}
### Summary
${summary}
### Changed
${commits:-- (no commits recorded)}
EOF
}
bump_semver() {
local current="$1"
local bump_type="$2"
+9 -3
View File
@@ -11,15 +11,16 @@ set -euo pipefail
BRANCH="${BRANCH:-renovate/auto-deps-update}"
BASE_BRANCH="${BASE_BRANCH:-main}"
LOCKFILE="${LOCKFILE:-uv.lock}"
PRE_COMMIT_CONFIG="${PRE_COMMIT_CONFIG:-.pre-commit-config.yaml}"
if git diff --quiet "$LOCKFILE"; then
if git diff --quiet "$LOCKFILE" && git diff --quiet "$PRE_COMMIT_CONFIG"; then
echo "No dependency updates available."
exit 0
fi
git config user.name "CI Bot"
git config user.email "[email protected]"
git add "$LOCKFILE"
git add "$LOCKFILE" "$PRE_COMMIT_CONFIG"
git commit -m "chore(deps): update dependencies [automated]"
git remote set-url origin "https://x-access-token:${CI_RUNNER_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
@@ -48,7 +49,12 @@ print(
json.dumps(
{
"title": "chore(deps): update dependencies",
"body": "This PR was created automatically to update dependencies.",
"body": (
"This PR was created automatically to update dependencies.\n\n"
"- `uv.lock`: upgraded Python dependencies\n"
"- `.pre-commit-config.yaml`: updated remote hook revs via "
"`pre-commit autoupdate`"
),
"head": os.environ["BRANCH"],
"base": os.environ["BASE_BRANCH"],
}
+2 -2
View File
@@ -34,13 +34,13 @@ if [[ -n "$PREV_TAG" ]]; then
## Changes since ${PREV_TAG}
$(git log "${PREV_TAG}..HEAD" --pretty=format:'- %h %s' || true)"
$(commits_since "$PREV_TAG")"
else
NOTES="${NOTES}
## Changes
$(git log --pretty=format:'- %h %s' -20 || true)"
$(commits_between "" HEAD 20)"
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
VERSION="${1:-}"
SUMMARY="${2:-}"
PREV_TAG="${3:-}"
DATE="${4:-$(date -u +%Y-%m-%d)}"
CHANGELOG="CHANGELOG.md"
if [[ -z "$VERSION" ]]; then
echo "Usage: update-changelog.sh <version> <summary> <prev-tag> [date]" >&2
exit 1
fi
if [[ -f "$CHANGELOG" ]] && grep -qF "## [${VERSION}]" "$CHANGELOG"; then
echo "CHANGELOG already contains version ${VERSION}; skipping." >&2
exit 0
fi
if [[ -n "$SUMMARY" ]]; then
SUMMARY_TEXT=$(strip_bump_prefix "$SUMMARY")
else
SUMMARY_TEXT="Automated release v${VERSION}."
fi
COMMITS=$(commits_since "$PREV_TAG")
SECTION=$(changelog_section "$VERSION" "$DATE" "$SUMMARY_TEXT" "$COMMITS")
if [[ ! -f "$CHANGELOG" ]]; then
changelog_header > "$CHANGELOG"
printf '\n%s\n' "$SECTION" >> "$CHANGELOG"
else
UNRELEASED_MARKER='## [Unreleased]'
if ! grep -qF "$UNRELEASED_MARKER" "$CHANGELOG"; then
echo "CHANGELOG.md exists but is missing ${UNRELEASED_MARKER}" >&2
exit 1
fi
TMP=$(mktemp)
SECTION_FILE=$(mktemp)
printf '%s\n' "$SECTION" > "$SECTION_FILE"
awk -v section_file="$SECTION_FILE" '
/^## \[Unreleased\]/ {
print
print ""
while ((getline line < section_file) > 0) {
print line
}
close(section_file)
next
}
{ print }
' "$CHANGELOG" > "$TMP"
rm -f "$SECTION_FILE"
mv "$TMP" "$CHANGELOG"
fi
echo "Updated ${CHANGELOG} with version ${VERSION}." >&2
-135
View File
@@ -1,51 +1,9 @@
"""Integration tests configuration."""
import logging
from collections.abc import Generator
from typing import Any, cast
import pytest
import redis
import structlog
from minio import Minio
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
from testcontainers.minio import MinioContainer
from python_repositories.config import MinioConfig, RedisConfig
REDIS_PORT = 6379
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket"
class _RedisPingWaitStrategy(WaitStrategy):
def __init__(self) -> None:
super().__init__()
self.with_transient_exceptions(redis.exceptions.ConnectionError)
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
redis_container = cast("RedisTestContainer", container)
if not self._poll(lambda: redis_container.get_client().ping()):
raise redis.exceptions.ConnectionError("Could not connect to Redis")
class RedisTestContainer(DockerContainer):
"""Redis container using wait strategies instead of the deprecated decorator."""
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
self.port = port
self.with_exposed_ports(self.port)
def get_client(self, **kwargs: Any) -> redis.Redis:
return redis.Redis(
host=self.get_container_host_ip(),
port=self.get_exposed_port(self.port),
**kwargs,
)
@pytest.fixture(scope="session", autouse=True)
@@ -64,96 +22,3 @@ def configure_logging() -> None:
cache_logger_on_first_use=True,
)
logging.basicConfig(level=logging.ERROR)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
container = RedisTestContainer(
image="redis/redis-stack:7.2.0-v0",
)
container.start()
redis_host = container.get_container_host_ip()
redis_port = container.get_exposed_port(REDIS_PORT)
redis_uri = f"redis://{redis_host}:{redis_port}"
yield redis_uri
container.stop()
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
container = MinioContainer(
image="minio/minio:latest",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
)
container.start()
minio_host = container.get_container_host_ip()
minio_port = container.get_exposed_port(9000)
minio_endpoint = f"{minio_host}:{minio_port}"
env_vars = {
"MINIO_ENDPOINT": minio_endpoint,
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
"MINIO_BUCKET": MINIO_BUCKET,
"MINIO_SECURE": "false",
}
yield env_vars
container.stop()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return RedisConfig(uri=redis_container)
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return MinioConfig(
endpoint=minio_container["MINIO_ENDPOINT"],
access_key=minio_container["MINIO_ACCESS_KEY"],
secret_key=minio_container["MINIO_SECRET_KEY"],
bucket=minio_container["MINIO_BUCKET"],
secure=False,
)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
client = redis.Redis.from_url(
url=redis_container,
socket_connect_timeout=10,
)
yield client
client.flushall()
client.close()
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
client = Minio(
endpoint=minio_container["MINIO_ENDPOINT"],
access_key=minio_container["MINIO_ACCESS_KEY"],
secret_key=minio_container["MINIO_SECRET_KEY"],
secure=False,
)
bucket_name = minio_container["MINIO_BUCKET"]
if not client.bucket_exists(bucket_name):
client.make_bucket(bucket_name)
yield client
objects = client.list_objects(bucket_name, recursive=True)
for obj in objects:
client.remove_object(bucket_name, obj.object_name)
+55
View File
@@ -0,0 +1,55 @@
"""Examples integration test fixtures."""
from collections.abc import Generator
from minio import Minio
import pytest
import redis
from python_repositories.config import MinioConfig, RedisConfig
from tests.integration.minio._containers import (
minio_config_from_env,
minio_env,
raw_minio_client_from_env,
)
from tests.integration.redis._containers import (
raw_redis_client_from_container,
redis_config_from_container,
redis_uri,
)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
yield from redis_uri()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return redis_config_from_container(redis_container)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
yield from raw_redis_client_from_container(redis_container)
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
yield from minio_env()
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return minio_config_from_env(minio_container)
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
yield from raw_minio_client_from_env(minio_container)
@@ -5,15 +5,19 @@ from io import BytesIO
import os
import random
import pytest
from minio import Minio
import pytest
from python_repositories.examples.artifact_object_repository import (
ArtifactObjectRepository,
)
from python_repositories.examples.user_json_repository import UserJsonRepository
pytestmark = pytest.mark.integration
pytestmark = [
pytest.mark.integration,
pytest.mark.needs_redis,
pytest.mark.needs_minio,
]
@pytest.fixture(scope="module", autouse=True)
+86
View File
@@ -0,0 +1,86 @@
"""MinIO container session helpers for integration tests."""
from collections.abc import Generator
from minio import Minio
from testcontainers.minio import MinioContainer
from python_repositories.config import MinioConfig
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket"
_minio_env: dict[str, str] | None = None
_minio_container: MinioContainer | None = None
_raw_minio_client: Minio | None = None
_minio_env_refs = 0
_raw_minio_client_refs = 0
def minio_env() -> Generator[dict[str, str], None, None]:
"""Yield session-scoped MinIO connection settings, starting the container once."""
global _minio_env, _minio_container, _minio_env_refs
if _minio_env is None:
_minio_container = MinioContainer(
image="minio/minio:latest",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
)
_minio_container.start()
minio_host = _minio_container.get_container_host_ip()
minio_port = _minio_container.get_exposed_port(9000)
minio_endpoint = f"{minio_host}:{minio_port}"
_minio_env = {
"MINIO_ENDPOINT": minio_endpoint,
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
"MINIO_BUCKET": MINIO_BUCKET,
"MINIO_SECURE": "false",
}
_minio_env_refs += 1
yield _minio_env
_minio_env_refs -= 1
if _minio_env_refs == 0 and _minio_container is not None:
_minio_container.stop()
_minio_container = None
_minio_env = None
def minio_config_from_env(env: dict[str, str]) -> MinioConfig:
"""Build MinioConfig from container environment settings."""
return MinioConfig(
endpoint=env["MINIO_ENDPOINT"],
access_key=env["MINIO_ACCESS_KEY"],
secret_key=env["MINIO_SECRET_KEY"],
bucket=env["MINIO_BUCKET"],
secure=False,
)
def raw_minio_client_from_env(env: dict[str, str]) -> Generator[Minio, None, None]:
"""Yield a session-scoped raw MinIO client, reusing one client per session."""
global _raw_minio_client, _raw_minio_client_refs
if _raw_minio_client is None:
_raw_minio_client = Minio(
endpoint=env["MINIO_ENDPOINT"],
access_key=env["MINIO_ACCESS_KEY"],
secret_key=env["MINIO_SECRET_KEY"],
secure=False,
)
bucket_name = env["MINIO_BUCKET"]
if not _raw_minio_client.bucket_exists(bucket_name):
_raw_minio_client.make_bucket(bucket_name)
_raw_minio_client_refs += 1
yield _raw_minio_client
_raw_minio_client_refs -= 1
if _raw_minio_client_refs == 0 and _raw_minio_client is not None:
bucket_name = env["MINIO_BUCKET"]
objects = _raw_minio_client.list_objects(bucket_name, recursive=True)
for obj in objects:
_raw_minio_client.remove_object(bucket_name, obj.object_name)
_raw_minio_client = None
+31
View File
@@ -0,0 +1,31 @@
"""MinIO integration test fixtures."""
from collections.abc import Generator
from minio import Minio
import pytest
from python_repositories.config import MinioConfig
from tests.integration.minio._containers import (
minio_config_from_env,
minio_env,
raw_minio_client_from_env,
)
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
yield from minio_env()
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return minio_config_from_env(minio_container)
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
yield from raw_minio_client_from_env(minio_container)
@@ -2,20 +2,20 @@
from collections.abc import Generator
from dataclasses import replace
from io import BytesIO
import logging
import random
from io import BytesIO
from unittest.mock import MagicMock
import pytest
from minio import Minio, S3Error
import pytest
from urllib3.response import BaseHTTPResponse
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.config import MinioConfig
from tests.conftest import TEST_MINIO_CONFIG
pytestmark = pytest.mark.integration
pytestmark = [pytest.mark.integration, pytest.mark.needs_minio]
def same_data(
@@ -102,7 +102,8 @@ def test_should_log_info_when_already_connected(
"""Test that the MinioAdapter logs info when connect is called while already connected."""
with caplog.at_level(logging.INFO):
minio_adapter.connect()
assert "Already connected to Minio" in caplog.text
assert "Already connected" in caplog.text
assert "Minio" in caplog.text
def test_should_raise_connection_error_when_unable_to_connect() -> None:
@@ -150,7 +151,8 @@ def test_connect_creates_bucket_when_create_bucket_if_missing_enabled(
with caplog.at_level(logging.INFO):
adapter.connect()
assert f"Creating bucket '{bucket_name}'" in caplog.text
assert "Creating bucket" in caplog.text
assert bucket_name in caplog.text
def test_should_log_error_on_exception_during_exit(
@@ -233,10 +235,11 @@ def test_should_log_warning_when_getting_nonexistent_object(
with caplog.at_level("WARNING"):
result = adapter.get(object_name)
assert result is None
assert (
f"Object '{object_name}' not found in bucket '{adapter._bucket_name}'"
in caplog.text
)
assert "Object not found" in caplog.text
assert object_name in caplog.text
bucket_name = adapter._bucket_name
assert bucket_name is not None
assert bucket_name in caplog.text
def test_should_reraise_s3error_other_than_no_such_key() -> None:
@@ -325,6 +328,44 @@ def test_should_raise_value_error_on_invalid_put_data(
minio_adapter.put(object_name, invalid) # type: ignore
def test_should_put_and_get_empty_bytesio(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that the MinioAdapter can put and get a zero-byte object."""
object_name = "empty_object"
empty_data = BytesIO()
assert minio_adapter.get(object_name) is None
minio_adapter.put(object_name, empty_data)
received_data = minio_adapter.get(object_name)
assert received_data is not None
assert same_data(empty_data, received_data)
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
def test_should_distinguish_missing_object_from_empty_object(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that missing objects and zero-byte objects are distinguishable."""
object_name = "empty_object"
minio_adapter.put(object_name, BytesIO())
assert minio_adapter.get("other_object") is None
minio_adapter.delete(object_name)
assert minio_adapter.get(object_name) is None
def test_should_list_zero_byte_object(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that a zero-byte object appears in object listings."""
object_name = "empty_object"
minio_adapter.put(object_name, BytesIO())
assert object_name in minio_adapter.list_objects()
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
def test_should_raise_value_error_on_invalid_put_content_type(
data: BytesIO,
minio_adapter: MinioAdapter,
+90
View File
@@ -0,0 +1,90 @@
"""Redis container session helpers for integration tests."""
from collections.abc import Generator
from typing import Any, cast
import redis
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
from python_repositories.config import RedisConfig
REDIS_PORT = 6379
class _RedisPingWaitStrategy(WaitStrategy):
def __init__(self) -> None:
super().__init__()
self.with_transient_exceptions(redis.exceptions.ConnectionError)
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
redis_container = cast("RedisTestContainer", container)
if not self._poll(lambda: redis_container.get_client().ping()):
raise redis.exceptions.ConnectionError("Could not connect to Redis")
class RedisTestContainer(DockerContainer):
"""Redis container using wait strategies instead of the deprecated decorator."""
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
self.port = port
self.with_exposed_ports(self.port)
def get_client(self, **kwargs: Any) -> redis.Redis:
return redis.Redis(
host=self.get_container_host_ip(),
port=self.get_exposed_port(self.port),
**kwargs,
)
_redis_uri: str | None = None
_redis_container: RedisTestContainer | None = None
_raw_redis_client: redis.Redis | None = None
_redis_uri_refs = 0
_raw_redis_client_refs = 0
def redis_uri() -> Generator[str, None, None]:
"""Yield a session-scoped Redis URI, starting the container once."""
global _redis_uri, _redis_container, _redis_uri_refs
if _redis_uri is None:
_redis_container = RedisTestContainer(image="redis/redis-stack:7.2.0-v0")
_redis_container.start()
redis_host = _redis_container.get_container_host_ip()
redis_port = _redis_container.get_exposed_port(REDIS_PORT)
_redis_uri = f"redis://{redis_host}:{redis_port}"
_redis_uri_refs += 1
yield _redis_uri
_redis_uri_refs -= 1
if _redis_uri_refs == 0 and _redis_container is not None:
_redis_container.stop()
_redis_container = None
_redis_uri = None
def redis_config_from_container(uri: str) -> RedisConfig:
"""Build RedisConfig from a container URI."""
return RedisConfig(uri=uri)
def raw_redis_client_from_container(uri: str) -> Generator[redis.Redis, None, None]:
"""Yield a session-scoped raw Redis client, reusing one client per session."""
global _raw_redis_client, _raw_redis_client_refs
if _raw_redis_client is None:
_raw_redis_client = redis.Redis.from_url(
url=uri,
socket_connect_timeout=10,
)
_raw_redis_client_refs += 1
yield _raw_redis_client
_raw_redis_client_refs -= 1
if _raw_redis_client_refs == 0 and _raw_redis_client is not None:
_raw_redis_client.flushall()
_raw_redis_client.close()
_raw_redis_client = None
+31
View File
@@ -0,0 +1,31 @@
"""Redis integration test fixtures."""
from collections.abc import Generator
import pytest
import redis
from python_repositories.config import RedisConfig
from tests.integration.redis._containers import (
raw_redis_client_from_container,
redis_config_from_container,
redis_uri,
)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
yield from redis_uri()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return redis_config_from_container(redis_container)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
yield from raw_redis_client_from_container(redis_container)
@@ -10,7 +10,7 @@ from redis.commands.json.path import Path as RedisPath
from python_repositories.adapters.redis_adapter import RedisAdapter
from python_repositories.config import RedisConfig
pytestmark = pytest.mark.integration
pytestmark = [pytest.mark.integration, pytest.mark.needs_redis]
@pytest.fixture(scope="module")
@@ -56,7 +56,8 @@ def test_should_log_info_when_already_connected(
"""Test that the RedisAdapter logs info when connect is called while already connected."""
with caplog.at_level(logging.INFO):
redis_adapter.connect()
assert "Already connected to Redis" in caplog.text
assert "Already connected" in caplog.text
assert "Redis" in caplog.text
def test_should_raise_connection_error_when_unable_to_connect() -> None:
@@ -189,12 +190,45 @@ def test_should_raise_value_error_on_invalid_set_data(
) -> None:
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
key = "test_key"
invalid_data = ["", 123, None, [], {}]
invalid_data = ["", 123, None, []]
for data in invalid_data:
with pytest.raises(ValueError):
redis_adapter.set(key, data) # type: ignore
def test_should_set_and_get_empty_dict(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter can set and get an empty dict."""
key = "empty_key"
assert redis_adapter.get(key) is None
redis_adapter.set(key, {})
value = redis_adapter.get(key)
assert value is not None
assert value == {}
def test_should_distinguish_missing_key_from_empty_dict(
redis_adapter: RedisAdapter,
) -> None:
"""Test that missing keys and empty dicts are distinguishable."""
key = "empty_key"
redis_adapter.set(key, {})
assert redis_adapter.get("other_key") is None
redis_adapter.delete(key)
assert redis_adapter.get(key) is None
def test_should_list_empty_dict_key(
redis_adapter: RedisAdapter,
) -> None:
"""Test that a key with an empty dict appears in key listings."""
key = "empty_key"
redis_adapter.set(key, {})
assert key in redis_adapter.list_keys(key)
assert key in list(redis_adapter.scan_keys(key))
def test_should_raise_connection_error_on_set_when_not_connected(
redis_config: RedisConfig,
data: dict[str, str],
+2 -2
View File
@@ -3,12 +3,12 @@
from __future__ import annotations
import builtins
from collections.abc import Callable, Mapping, Sequence
import importlib
import os
from pathlib import Path
import subprocess
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
+1 -1
View File
@@ -4,9 +4,9 @@ from __future__ import annotations
from unittest.mock import MagicMock
from minio import Minio
import pytest
import redis
from minio import Minio
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.adapters.redis_adapter import RedisAdapter
@@ -1,11 +1,30 @@
"""Unit tests for ConnectionAwareInterface."""
import pytest
from python_repositories.interfaces.connection_aware_interface import (
ConnectionAwareInterface,
)
class FakeConnection:
"""Plain class that satisfies ConnectionAwareInterface without inheritance."""
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def is_connected(self) -> bool:
return True
def accepts_connection_aware(connection: ConnectionAwareInterface) -> None:
"""Type-checking hook for ConnectionAwareInterface structural subtyping."""
connection.is_connected()
def test_instantiation_fails_when_connect_not_implemented() -> None:
"""Test that instantiation fails if connect is not implemented."""
@@ -52,3 +71,10 @@ def test_instantiation_fails_when_is_connected_not_implemented() -> None:
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_structural_subtyping() -> None:
"""Test that a plain class satisfies ConnectionAwareInterface structurally."""
connection: ConnectionAwareInterface = FakeConnection()
accepts_connection_aware(connection)
assert isinstance(connection, ConnectionAwareInterface)
@@ -3,9 +3,28 @@
from __future__ import annotations
import pytest
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
class FakeContextManager:
"""Plain class that satisfies ContextAwareInterface without inheritance."""
def __enter__(self) -> FakeContextManager:
return self
def __exit__(
self, exc_type: type | None, exc_val: object | None, exc_tb: object | None
) -> None:
pass
def accepts_context_aware(context: ContextAwareInterface) -> None:
"""Type-checking hook for ContextAwareInterface structural subtyping."""
with context:
pass
def test_instantiation_fails_when_enter_not_implemented() -> None:
"""Test that instantiation fails if __enter__ is not implemented."""
@@ -32,3 +51,10 @@ def test_instantiation_fails_when_exit_not_implemented() -> None:
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_structural_subtyping() -> None:
"""Test that a plain class satisfies ContextAwareInterface structurally."""
context: ContextAwareInterface = FakeContextManager()
accepts_context_aware(context)
assert isinstance(context, ContextAwareInterface)
+49 -8
View File
@@ -1,18 +1,52 @@
"""Unit tests for JsonRepositoryInterface."""
from collections.abc import Iterator
from typing import Any
import pytest
from python_repositories.interfaces.json_repository_interface import (
JsonRepositoryInterface,
)
class InMemoryJsonRepo:
"""Plain class that satisfies JsonRepositoryInterface without inheritance."""
def get(self, key: str) -> dict[str, Any] | None:
return None
def set(self, key: str, data: dict[str, Any]) -> None:
pass
def delete(self, key: str) -> None:
pass
def list_keys(self, pattern: str) -> list[str]:
return []
def scan_keys(
self,
pattern: str,
*,
count: int | None = None,
) -> Iterator[str]:
del count
yield from self.list_keys(pattern)
def accepts_json_repo(repo: JsonRepositoryInterface) -> None:
"""Type-checking hook for JsonRepositoryInterface structural subtyping."""
repo.get("key")
def test_instantiation_fails_when_get_not_implemented() -> None:
"""Test that instantiation fails if get is not implemented."""
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement get."""
def set(self, key: str, data: dict) -> None:
def set(self, key: str, data: dict[str, Any]) -> None:
pass
def delete(self, key: str) -> None:
@@ -31,7 +65,7 @@ def test_instantiation_fails_when_set_not_implemented() -> None:
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement set."""
def get(self, key: str) -> dict | None:
def get(self, key: str) -> dict[str, Any] | None:
return None
def delete(self, key: str) -> None:
@@ -50,10 +84,10 @@ def test_instantiation_fails_when_delete_not_implemented() -> None:
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement delete."""
def get(self, key: str) -> dict | None:
def get(self, key: str) -> dict[str, Any] | None:
return None
def set(self, key: str, data: dict) -> None:
def set(self, key: str, data: dict[str, Any]) -> None:
pass
def list_keys(self, pattern: str) -> list[str]:
@@ -69,10 +103,10 @@ def test_instantiation_fails_when_list_keys_not_implemented() -> None:
class Incomplete(JsonRepositoryInterface):
"""A class that does not implement list_keys."""
def get(self, key: str) -> dict | None:
def get(self, key: str) -> dict[str, Any] | None:
return None
def set(self, key: str, data: dict) -> None:
def set(self, key: str, data: dict[str, Any]) -> None:
pass
def delete(self, key: str) -> None:
@@ -86,10 +120,10 @@ def test_scan_keys_defaults_to_list_keys() -> None:
"""Test that the default scan_keys implementation delegates to list_keys."""
class Complete(JsonRepositoryInterface):
def get(self, key: str) -> dict | None:
def get(self, key: str) -> dict[str, Any] | None:
return None
def set(self, key: str, data: dict) -> None:
def set(self, key: str, data: dict[str, Any]) -> None:
pass
def delete(self, key: str) -> None:
@@ -101,3 +135,10 @@ def test_scan_keys_defaults_to_list_keys() -> None:
repository = Complete()
assert list(repository.scan_keys("user")) == ["user-1", "user-2"]
def test_structural_subtyping() -> None:
"""Test that a plain class satisfies JsonRepositoryInterface structurally."""
repo: JsonRepositoryInterface = InMemoryJsonRepo()
accepts_json_repo(repo)
assert isinstance(repo, JsonRepositoryInterface)
+1 -1
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
from dataclasses import replace
from unittest.mock import MagicMock
import pytest
from minio import Minio, S3Error
import pytest
from urllib3.response import BaseHTTPResponse
from python_repositories.adapters.minio_adapter import MinioAdapter
@@ -3,11 +3,38 @@
from io import BytesIO
import pytest
from python_repositories.interfaces.object_repository_interface import (
ObjectRepositoryInterface,
)
class InMemoryObjectRepo:
"""Plain class that satisfies ObjectRepositoryInterface without inheritance."""
def get(self, object_name: str) -> BytesIO | None:
return None
def put(
self,
object_name: str,
data: BytesIO,
content_type: str = "application/octet-stream",
) -> None:
pass
def delete(self, object_name: str) -> None:
pass
def list_objects(self, prefix: str = "") -> list[str]:
return []
def accepts_object_repo(repo: ObjectRepositoryInterface) -> None:
"""Type-checking hook for ObjectRepositoryInterface structural subtyping."""
repo.get("object")
def test_instantiation_fails_when_get_not_implemented() -> None:
"""Test that instantiation fails if get is not implemented."""
@@ -97,3 +124,10 @@ def test_instantiation_fails_when_list_objects_not_implemented() -> None:
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_structural_subtyping() -> None:
"""Test that a plain class satisfies ObjectRepositoryInterface structurally."""
repo: ObjectRepositoryInterface = InMemoryObjectRepo()
accepts_object_repo(repo)
assert isinstance(repo, ObjectRepositoryInterface)
Generated
+6 -55
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -1056,7 +1056,7 @@ wheels = [
[[package]]
name = "python-repositories"
version = "2.0.1"
version = "2.1.0"
source = { editable = "." }
dependencies = [
{ name = "python-dotenv" },
@@ -1082,7 +1082,6 @@ dev = [
{ name = "ruff" },
{ name = "safety" },
{ name = "testcontainers" },
{ name = "types-redis" },
]
[package.metadata]
@@ -1097,15 +1096,14 @@ provides-extras = ["redis", "minio"]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.17.1" },
{ name = "pre-commit", specifier = ">=4.3.0" },
{ name = "mypy", specifier = ">=2.1.0" },
{ name = "pre-commit", specifier = ">=4.6.0" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pyupgrade", specifier = ">=3.20.0" },
{ name = "ruff", specifier = ">=0.12.11" },
{ name = "pyupgrade", specifier = ">=3.21.2" },
{ name = "ruff", specifier = ">=0.15.20" },
{ name = "safety", specifier = ">=3.6.0" },
{ name = "testcontainers", specifier = ">=4.13.0" },
{ name = "types-redis", specifier = ">=4.6.0.20241004" },
]
[[package]]
@@ -1495,53 +1493,6 @@ wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/typer/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89" },
]
[[package]]
name = "types-cffi"
version = "2.0.0.20260518"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/types-cffi/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/types-cffi/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084" },
]
[[package]]
name = "types-pyopenssl"
version = "24.1.0.20240722"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
dependencies = [
{ name = "cryptography" },
{ name = "types-cffi" },
]
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/types-pyopenssl/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/types-pyopenssl/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54" },
]
[[package]]
name = "types-redis"
version = "4.6.0.20241004"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
dependencies = [
{ name = "cryptography" },
{ name = "types-pyopenssl" },
]
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/types-redis/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/types-redis/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed" },
]
[[package]]
name = "types-setuptools"
version = "82.0.0.20260518"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/types-setuptools/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/types-setuptools/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"