compat-sentinel 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ .DS_Store
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .coverage
6
+ htmlcov/
7
+ *.pyc
8
+ build/
9
+ dist/
10
+ *.egg-info/
11
+ tests/.uv-venvs/
12
+ *-old.py
@@ -0,0 +1,92 @@
1
+ # AGENTS
2
+
3
+ ## Scope
4
+
5
+ This file defines repository-specific working instructions for `compat-sentinel`.
6
+
7
+ ## Agent requests: review vs edit
8
+
9
+ - When the user asks to review, verify, analyze, assess, report, or check,
10
+ respond with read-only analysis only.
11
+ - Do not change files or implement fixes unless the user explicitly asks for
12
+ edits, fixes, implementation, or an update.
13
+ - If analysis surfaces a problem, describe it and wait for direction rather
14
+ than patching the tree unprompted.
15
+
16
+ ## Start here
17
+
18
+ - `README.md` is the public contract: `sentinel(name, /, *, repr=None)`,
19
+ matching PEP 661.
20
+ - On Python 3.15 and later, `compat_sentinel.sentinel` is `builtins.sentinel`.
21
+ Earlier versions use `src/compat_sentinel/_sentinel.py`.
22
+ - Pickle identity comes from importing `__module__` and looking up `__name__`.
23
+ There is no sentinel registry.
24
+
25
+ ## Roll-build method
26
+
27
+ - When the user asks for a phased rollout using the roll-build method, start
28
+ from a clean git tree and tag that point before implementation begins.
29
+ - Use the requested start tag name when one is given. If none is given, ask or
30
+ use a clearly scoped phase-start tag name.
31
+ - An unqualified `roll-build` means: run all phases for that plan in sequence,
32
+ committing and tagging each completed phase, and continue into the next phase
33
+ without stopping unless the guardrails below require a pause.
34
+ - Run the roll-build in the current owning checkout and current branch. Do not
35
+ create git worktrees, sibling checkouts, or parallel rollout branches unless
36
+ the user explicitly asks for them in that request.
37
+ - Do not split phases or adjacent roll-build requests into parallel branches.
38
+ If one roll-build has already produced commits, the next roll-build starts on
39
+ top of those commits after they are integrated into the current branch.
40
+ - If the current branch is not the intended integration branch, stop and ask
41
+ before creating or switching branches. Do not invent a branch/worktree strategy
42
+ from the tag prefix.
43
+ - Implement one phase at a time.
44
+ - After a phase is complete, only commit and tag it if:
45
+ - the phase goal is actually met
46
+ - focused verification passes
47
+ - the remaining ambiguities are minor and non-blocking
48
+ - If there are no more phases, or if confidence drops because of material
49
+ ambiguity or instability, stop and wait instead of forcing the next phase.
50
+ - If work starts cycling on the same persistent bug or bug family, stop, report
51
+ the cycle clearly, and ask for direction.
52
+
53
+ ## When to push back on roll-build
54
+
55
+ - Push back when the next phase has too many unresolved ambiguities to produce a
56
+ trustworthy checkpoint.
57
+ - Push back when the requested phase is too large or too coupled to complete
58
+ safely as one checkpoint.
59
+ - Push back when implementation reveals facts that materially break the current
60
+ design or plan assumptions.
61
+ - Push back when the resulting checkpoint would be misleadingly partial,
62
+ unstable, or hard to recover from.
63
+
64
+ ## Test-led semantics guardrail
65
+
66
+ - Do not change public semantics merely to make a test pass without updating the
67
+ design/docs.
68
+ - If a red test implies a real semantic change rather than a bug fix or missing
69
+ coverage, stop and update the design/docs before implementing the change.
70
+ - It is acceptable to tighten tests, fix assumptions, or fix correctness bugs
71
+ that clearly match the current design intent.
72
+ - It is not acceptable to quietly redefine semantics to satisfy a convenient
73
+ test expectation.
74
+
75
+ ## Test commands
76
+
77
+ - Focused tests: `uv run --with pytest pytest <test-path> -q`
78
+ - Full suite: `uv run --with pytest pytest -q`
79
+
80
+ <!-- gearu:agents:start -->
81
+ ## Releases
82
+
83
+ - This repository uses [Gearu](https://owebeeone.github.io/gearu/) for release
84
+ preparation.
85
+ - Read `RELEASE.md` before planning or performing a release.
86
+ - `gearu plan VERSION` and `gearu plan --bump LEVEL` are read-only. Do not run
87
+ `gearu release`, push a release tag, or create a GitHub Release unless the
88
+ user explicitly requests it.
89
+ - Never move or reuse a release tag. Correct released content with a new version.
90
+ - Never publish directly to PyPI, crates.io, or npm from a local checkout.
91
+ Registry publication belongs in the repository's release workflow.
92
+ <!-- gearu:agents:end -->
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gianni Mariani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: compat-sentinel
3
+ Version: 0.1.0
4
+ Summary: PEP 661 sentinel for Python versions before 3.15
5
+ Project-URL: Repository, https://github.com/owebeeone/compat-sentinel
6
+ Project-URL: Issues, https://github.com/owebeeone/compat-sentinel/issues
7
+ Project-URL: Source, https://github.com/owebeeone/compat-sentinel
8
+ Author: compat-sentinel contributors
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Gianni Mariani
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in
21
+ all copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: compatibility,pep661,pickle,sentinel
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Classifier: Programming Language :: Python :: 3.15
40
+ Requires-Python: >=3.12
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest>=7.0; extra == 'dev'
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest>=7.0; extra == 'test'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # compat-sentinel
48
+
49
+ PEP 661 `sentinel` for Python 3.12 and later.
50
+
51
+ On Python 3.15 and later, `compat_sentinel.sentinel` is the builtin. On older
52
+ interpreters it is a local implementation with the same constructor, copy
53
+ behavior, and pickle lookup.
54
+
55
+ ```python
56
+ from compat_sentinel import sentinel
57
+
58
+ MISSING = sentinel("MISSING")
59
+ DEFAULT = sentinel("DEFAULT", repr="<default>")
60
+ ```
61
+
62
+ Each call returns a new object. A sentinel pickles back to itself when it can
63
+ be imported from its module under `__name__`:
64
+
65
+ ```python
66
+ MISSING = sentinel("MISSING")
67
+
68
+ class Box:
69
+ SHORT = sentinel("Box.SHORT")
70
+ ```
71
+
72
+ A sentinel created in a local scope and never stored under that name does not
73
+ pickle. `__module__` is taken from the caller and is writable; pickle uses the
74
+ value present at dump time.
75
+
76
+ `int | MISSING` and `MISSING | str` build a `typing.Union`.
@@ -0,0 +1,30 @@
1
+ # compat-sentinel
2
+
3
+ PEP 661 `sentinel` for Python 3.12 and later.
4
+
5
+ On Python 3.15 and later, `compat_sentinel.sentinel` is the builtin. On older
6
+ interpreters it is a local implementation with the same constructor, copy
7
+ behavior, and pickle lookup.
8
+
9
+ ```python
10
+ from compat_sentinel import sentinel
11
+
12
+ MISSING = sentinel("MISSING")
13
+ DEFAULT = sentinel("DEFAULT", repr="<default>")
14
+ ```
15
+
16
+ Each call returns a new object. A sentinel pickles back to itself when it can
17
+ be imported from its module under `__name__`:
18
+
19
+ ```python
20
+ MISSING = sentinel("MISSING")
21
+
22
+ class Box:
23
+ SHORT = sentinel("Box.SHORT")
24
+ ```
25
+
26
+ A sentinel created in a local scope and never stored under that name does not
27
+ pickle. `__module__` is taken from the caller and is writable; pickle uses the
28
+ value present at dump time.
29
+
30
+ `int | MISSING` and `MISSING | str` build a `typing.Union`.
@@ -0,0 +1,150 @@
1
+ # Release Process
2
+
3
+ ## compat-sentinel Configuration
4
+
5
+ `gearu.toml` releases the Python distribution from `main`, using immutable
6
+ `v<version>` tags and the `owebeeone/compat-sentinel` GitHub repository.
7
+
8
+ - Gearu updates `project.version` in `pyproject.toml` and refreshes `uv.lock`.
9
+ - The first candidate check synchronizes the public `compat_sentinel.__version__`
10
+ mirror in `src/compat_sentinel/__init__.py`. Verification after committing only
11
+ checks that mirror; it never repairs a tagged candidate.
12
+ - Candidate and exact-commit verification run pytest on every version listed in
13
+ `tool.compat-sentinel.test-matrix` and check the sdist and wheel with Twine.
14
+ - GitHub Release creation triggers `.github/workflows/publish.yml`, which builds
15
+ a pure-Python wheel and sdist and publishes them through PyPI trusted
16
+ publishing for `owebeeone/compat-sentinel`. Gearu does not upload packages
17
+ locally.
18
+
19
+ Prerequisites: Gearu 0.1.1 or later, Git, `uv`, and `gh` authenticated for this
20
+ repository when publishing a GitHub Release. Run all commands from the
21
+ repository root. Commit the configuration and docs changes before asking Gearu
22
+ to plan: even planning requires a clean tree.
23
+
24
+ The configuration contains no fixed next version. Start with
25
+ `gearu plan --bump patch`, review its proposed version, and use that explicit
26
+ version for any subsequently authorized release. Planning does not run the
27
+ checks; it reports the intended changes and commands. Gearu's plan lists the
28
+ manifest update, while the version mirror and lockfile are refreshed during
29
+ candidate preparation.
30
+
31
+ <!-- gearu:release:start -->
32
+ ## Gearu Release Process
33
+
34
+ Gearu prepares and verifies the repository, creates an immutable tag, and can
35
+ create the GitHub Release that starts this repository's publication workflow.
36
+ It does not publish directly to package registries.
37
+
38
+ Full documentation: <https://owebeeone.github.io/gearu/>
39
+
40
+ ### Install
41
+
42
+ Install the released tool with:
43
+
44
+ ```sh
45
+ uv tool install gearu
46
+ ```
47
+
48
+ Upgrade an existing installation with:
49
+
50
+ ```sh
51
+ uv tool upgrade gearu
52
+ ```
53
+
54
+ To test the unreleased `main` branch, install it directly from its repository:
55
+
56
+ ```sh
57
+ uv tool install git+https://github.com/owebeeone/gearu.git
58
+ ```
59
+
60
+ Verify the installation with `gearu --version`.
61
+
62
+ ### Preconditions
63
+
64
+ - Read `gearu.toml` and this repository's release workflow.
65
+ - Choose an explicit release version or an explicit major, minor, or patch bump.
66
+ Gearu does not infer release intent from commits.
67
+ - Use a clean checkout on the branch configured by `project.branch`.
68
+ - Synchronize configured release and source branches with their remote.
69
+ - Release required cross-repository dependencies first.
70
+ - Install and authenticate `gh` before requesting GitHub Release creation.
71
+
72
+ ### Plan
73
+
74
+ Always inspect the read-only plan first:
75
+
76
+ ```sh
77
+ gearu plan VERSION
78
+ ```
79
+
80
+ Or ask Gearu to select the next version:
81
+
82
+ ```sh
83
+ gearu plan --bump patch
84
+ gearu plan --bump minor
85
+ gearu plan --bump major
86
+ ```
87
+
88
+ Gearu compares configured package versions with valid local and remote release
89
+ tags, then bumps the highest version. It reads remote tags directly and does not
90
+ fetch or create local tags while planning.
91
+
92
+ For a release candidate, use a numbered version such as `1.2.3-rc.1`.
93
+
94
+ Override a configured dependency tag only when the release intentionally uses a
95
+ different version:
96
+
97
+ ```sh
98
+ gearu plan VERSION --dependency-tag DEPENDENCY=TAG
99
+ ```
100
+
101
+ ### Prepare the Local Release
102
+
103
+ After reviewing the plan:
104
+
105
+ ```sh
106
+ gearu release VERSION
107
+ ```
108
+
109
+ The release command can select the version itself:
110
+
111
+ ```sh
112
+ gearu release --bump minor
113
+ ```
114
+
115
+ This recalculates the next version at release time. To lock the version reviewed
116
+ in a prior bump plan, pass that plan's reported `VERSION` explicitly.
117
+
118
+ Gearu builds and tests in a temporary worktree. Only a successful candidate is
119
+ applied to the local release branch and tagged. This step does not change a
120
+ remote repository.
121
+
122
+ ### Push and Create the GitHub Release
123
+
124
+ Push the exact release commit and tag atomically:
125
+
126
+ ```sh
127
+ gearu release VERSION --push
128
+ ```
129
+
130
+ Create the GitHub Release after that push:
131
+
132
+ ```sh
133
+ gearu release VERSION --push --github-release
134
+ ```
135
+
136
+ The final command starts workflows listening for `release.published`, including
137
+ package publication and documentation deployment where configured.
138
+
139
+ ### Recovery
140
+
141
+ - If candidate checks fail, fix the problem and rerun; the normal checkout is
142
+ left unchanged.
143
+ - If local preparation succeeds, rerun the same version with `--push`.
144
+ - If the push succeeds but GitHub Release creation fails, rerun with
145
+ `--push --github-release`.
146
+ - If released contents must change, use a new patch or release-candidate version.
147
+ Never move or replace the existing tag.
148
+ - If only a publication workflow fails, repair and rerun that workflow for the
149
+ same GitHub Release.
150
+ <!-- gearu:release:end -->
@@ -0,0 +1,15 @@
1
+ """compat_sentinel — PEP 661 sentinel for Python 3.12 and later."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ try:
6
+ from builtins import sentinel as sentinel
7
+ except ImportError:
8
+ from compat_sentinel._sentinel import sentinel
9
+
10
+ sentinel.__module__ = __name__
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "sentinel",
15
+ ]
@@ -0,0 +1,98 @@
1
+ """PEP 661 ``sentinel`` for interpreters that do not provide the builtin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Union
7
+
8
+
9
+ class sentinel:
10
+ """Unique sentinel object.
11
+
12
+ ``sentinel(name, /, *, repr=None)`` matches the Python 3.15 builtin.
13
+ Each call returns a new object. Pickle preserves identity only when the
14
+ sentinel can be imported from its ``__module__`` under ``__name__``.
15
+ """
16
+
17
+ __slots__ = ("_name", "_module", "_repr")
18
+
19
+ def __init_subclass__(cls) -> None:
20
+ raise TypeError("subclassing sentinel is not supported")
21
+
22
+ def __new__(cls, name: str, /, *, repr: str | None = None) -> sentinel:
23
+ if cls is not sentinel:
24
+ raise TypeError("sentinel() may not be invoked from a subclass")
25
+ if not isinstance(name, str):
26
+ raise TypeError(f"sentinel name must be a str, not {type(name).__name__}")
27
+ if repr is not None and not isinstance(repr, str):
28
+ raise TypeError(f"sentinel repr must be a str, not {type(repr).__name__}")
29
+
30
+ instance = object.__new__(cls)
31
+ object.__setattr__(instance, "_name", name)
32
+ object.__setattr__(instance, "_module", _caller_module())
33
+ object.__setattr__(instance, "_repr", name if repr is None else repr)
34
+ return instance
35
+
36
+ def __getattribute__(self, name: str) -> object:
37
+ if name == "__name__":
38
+ return object.__getattribute__(self, "_name")
39
+ if name == "__module__":
40
+ return object.__getattribute__(self, "_module")
41
+ return object.__getattribute__(self, name)
42
+
43
+ def __setattr__(self, name: str, value: object) -> None:
44
+ if name == "__module__":
45
+ if not isinstance(value, str):
46
+ raise TypeError(f"__module__ must be a str, not {type(value).__name__}")
47
+ object.__setattr__(self, "_module", value)
48
+ return
49
+ if name == "__name__":
50
+ raise AttributeError("readonly attribute")
51
+ raise AttributeError(f"'sentinel' object has no attribute {name!r}")
52
+
53
+ def __delattr__(self, name: str) -> None:
54
+ raise AttributeError(f"'sentinel' object has no attribute {name!r}")
55
+
56
+ def __repr__(self) -> str:
57
+ return object.__getattribute__(self, "_repr")
58
+
59
+ def __str__(self) -> str:
60
+ return object.__getattribute__(self, "_repr")
61
+
62
+ def __bool__(self) -> bool:
63
+ return True
64
+
65
+ def __hash__(self) -> int:
66
+ return object.__hash__(self)
67
+
68
+ def __eq__(self, other: object) -> bool:
69
+ return self is other
70
+
71
+ def __copy__(self) -> sentinel:
72
+ return self
73
+
74
+ def __deepcopy__(self, memo: object) -> sentinel:
75
+ return self
76
+
77
+ def __reduce__(self) -> str:
78
+ return object.__getattribute__(self, "_name")
79
+
80
+ def __or__(self, other: object) -> object:
81
+ return Union[self, other]
82
+
83
+ def __ror__(self, other: object) -> object:
84
+ return Union[other, self]
85
+
86
+
87
+ def _caller_module() -> str:
88
+ """Return the module that called ``sentinel()``.
89
+
90
+ ``__new__`` is the direct caller, so the user frame is one level above it.
91
+ """
92
+
93
+ module_name = sys._getframemodulename(2)
94
+ if module_name is None:
95
+ module_name = sys._getframe(2).f_globals.get("__name__", "__main__")
96
+ if not isinstance(module_name, str) or module_name == "":
97
+ return "__main__"
98
+ return module_name
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "compat-sentinel"
3
+ branch = "main"
4
+ remote = "origin"
5
+ tag_prefix = "v"
6
+ github_repo = "owebeeone/compat-sentinel"
7
+
8
+ [python]
9
+ version = "static"
10
+ manifest = "pyproject.toml"
11
+ lockfiles = ["uv.lock"]
12
+ lock_command = ["uv", "lock", "--python", "3.12"]
13
+
14
+ [release]
15
+ managed_files = ["src/compat_sentinel/__init__.py"]
16
+ checks = [
17
+ ["uv", "run", "--no-project", "--python", "3.12", "python", "scripts/release_checks.py", "sync-version", "{python_version}"],
18
+ ["uv", "run", "--no-project", "--python", "3.12", "python", "scripts/release_checks.py", "verify-version", "{python_version}"],
19
+ ["uv", "run", "--no-project", "--python", "3.12", "python", "scripts/release_checks.py", "test-matrix", "{python_version}"],
20
+ ["uv", "run", "--no-project", "--python", "3.12", "--with", "build", "--with", "twine", "python", "scripts/release_checks.py", "distributions", "{python_version}"],
21
+ ]
22
+ # Verification after the candidate commit must not rewrite its version mirror.
23
+ exact_checks = [
24
+ ["uv", "run", "--no-project", "--python", "3.12", "python", "scripts/release_checks.py", "verify-version", "{python_version}"],
25
+ ["uv", "run", "--no-project", "--python", "3.12", "python", "scripts/release_checks.py", "test-matrix", "{python_version}"],
26
+ ["uv", "run", "--no-project", "--python", "3.12", "--with", "build", "--with", "twine", "python", "scripts/release_checks.py", "distributions", "{python_version}"],
27
+ ]
28
+ github_notes = """
29
+ compat-sentinel {tag}.
30
+
31
+ See the commit history for changes. The release workflow verifies the supported
32
+ Python versions and publishes a pure-Python wheel and source distribution to
33
+ PyPI.
34
+ """
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "compat-sentinel"
7
+ version = "0.1.0"
8
+ description = "PEP 661 sentinel for Python versions before 3.15"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [{ name = "compat-sentinel contributors" }]
12
+ license = { file = "LICENSE" }
13
+ keywords = ["sentinel", "pep661", "compatibility", "pickle"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Programming Language :: Python :: 3.15",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/owebeeone/compat-sentinel"
27
+ Issues = "https://github.com/owebeeone/compat-sentinel/issues"
28
+ Source = "https://github.com/owebeeone/compat-sentinel"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7.0"]
32
+ test = ["pytest>=7.0"]
33
+
34
+ [tool.uv]
35
+ exclude-newer = "1 week"
36
+
37
+ [tool.hatch.version]
38
+ path = "src/compat_sentinel/__init__.py"
39
+
40
+ [tool.hatch.build]
41
+ sources = ["src"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = [
45
+ "src/compat_sentinel",
46
+ "tests",
47
+ "scripts/release_checks.py",
48
+ "gearu.toml",
49
+ "RELEASE.md",
50
+ "AGENTS.md",
51
+ "LICENSE",
52
+ "README.md",
53
+ ]
54
+
55
+ # Run: uv run --with pytest pytest -q
56
+ [tool.pytest.ini_options]
57
+ pythonpath = [".", "src", "tests"]
58
+ testpaths = ["tests"]
59
+
60
+ [tool.compat-sentinel.test-matrix]
61
+ python = ["3.12", "3.13", "3.14", "3.15"]
62
+
63
+ [tool.pyright]
64
+ extraPaths = ["src"]
65
+
66
+ [tool.basedpyright]
67
+ extraPaths = ["src"]
@@ -0,0 +1,162 @@
1
+ """Version synchronization and isolated distribution checks for Gearu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import tomllib
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ PACKAGE = "compat-sentinel"
17
+
18
+
19
+ def _version_assignment(source: str) -> ast.Assign:
20
+ assignments = [
21
+ node
22
+ for node in ast.parse(source).body
23
+ if isinstance(node, ast.Assign)
24
+ and any(isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets)
25
+ ]
26
+ if len(assignments) != 1:
27
+ raise ValueError("expected one top-level __version__ assignment")
28
+ assignment = assignments[0]
29
+ if (
30
+ len(assignment.targets) != 1
31
+ or not isinstance(assignment.value, ast.Constant)
32
+ or not isinstance(assignment.value.value, str)
33
+ or assignment.lineno != assignment.end_lineno
34
+ ):
35
+ raise ValueError("__version__ must be a standalone string assignment")
36
+ line = source.splitlines()[assignment.lineno - 1]
37
+ if line[:assignment.col_offset].strip() or line[assignment.end_col_offset:].strip():
38
+ raise ValueError("__version__ must be a standalone string assignment")
39
+ return assignment
40
+
41
+
42
+ def _project(root: Path) -> dict:
43
+ return tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
44
+
45
+
46
+ def _verify_manifest(root: Path, version: str) -> None:
47
+ project = _project(root)["project"]
48
+ if project["name"] != PACKAGE or project["version"] != version:
49
+ raise ValueError(f"pyproject.toml must declare {PACKAGE} {version}")
50
+
51
+
52
+ def sync_version(root: Path, version: str) -> None:
53
+ _verify_manifest(root, version)
54
+ path = root / "src" / "compat_sentinel" / "__init__.py"
55
+ source = path.read_text(encoding="utf-8")
56
+ assignment = _version_assignment(source)
57
+ if assignment.value.value == version:
58
+ return
59
+ lines = source.splitlines(keepends=True)
60
+ lines[assignment.lineno - 1] = f"__version__ = {version!r}\n"
61
+ path.write_text("".join(lines), encoding="utf-8")
62
+
63
+
64
+ def verify_version(root: Path, version: str) -> None:
65
+ _verify_manifest(root, version)
66
+ source = (root / "src" / "compat_sentinel" / "__init__.py").read_text(encoding="utf-8")
67
+ if _version_assignment(source).value.value != version:
68
+ raise ValueError(f"compat_sentinel.__version__ must be {version}")
69
+ lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8"))
70
+ packages = [package for package in lock["package"] if package["name"] == PACKAGE]
71
+ if len(packages) != 1 or packages[0]["version"] != version:
72
+ raise ValueError(f"uv.lock must record {PACKAGE} {version}")
73
+
74
+
75
+ def check_test_matrix(root: Path, version: str) -> None:
76
+ verify_version(root, version)
77
+ versions = _project(root)["tool"]["compat-sentinel"]["test-matrix"]["python"]
78
+ for python in versions:
79
+ subprocess.run(
80
+ [
81
+ "uv", "run", "--no-project", "--python", str(python),
82
+ "--with", "pytest", "pytest", "-q",
83
+ ],
84
+ cwd=root,
85
+ check=True,
86
+ )
87
+
88
+
89
+ def check_distributions(root: Path, version: str) -> None:
90
+ verify_version(root, version)
91
+ env = os.environ.copy()
92
+ env.pop("PYTHONPATH", None)
93
+ env.pop("PYTHONHOME", None)
94
+ with tempfile.TemporaryDirectory(prefix="compat-sentinel-release-check-") as directory:
95
+ temporary = Path(directory)
96
+ dist = temporary / "dist"
97
+ subprocess.run(
98
+ [sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", str(dist)],
99
+ cwd=root,
100
+ env=env,
101
+ check=True,
102
+ )
103
+ wheels = list(dist.glob("*.whl"))
104
+ sdists = list(dist.glob("*.tar.gz"))
105
+ if len(wheels) != 1 or len(sdists) != 1:
106
+ raise ValueError("expected exactly one wheel and one source distribution")
107
+ subprocess.run(
108
+ [sys.executable, "-m", "twine", "check", str(wheels[0]), str(sdists[0])],
109
+ cwd=temporary,
110
+ env=env,
111
+ check=True,
112
+ )
113
+ environment = temporary / "venv"
114
+ subprocess.run(
115
+ ["uv", "venv", "--python", sys.executable, str(environment)],
116
+ cwd=temporary,
117
+ env=env,
118
+ check=True,
119
+ )
120
+ python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
121
+ subprocess.run(
122
+ ["uv", "pip", "install", "--python", str(python), str(wheels[0])],
123
+ cwd=temporary,
124
+ env=env,
125
+ check=True,
126
+ )
127
+ subprocess.run(
128
+ [
129
+ str(python), "-I", "-c",
130
+ "import copy, sys; from importlib.metadata import version; "
131
+ "import compat_sentinel; from compat_sentinel import sentinel; "
132
+ "assert compat_sentinel.__version__ == version('compat-sentinel') == sys.argv[1]; "
133
+ "missing = sentinel('MISSING'); "
134
+ "assert repr(missing) == 'MISSING'; "
135
+ "assert copy.copy(missing) is missing",
136
+ version,
137
+ ],
138
+ cwd=temporary,
139
+ env=env,
140
+ check=True,
141
+ )
142
+
143
+
144
+ def main() -> None:
145
+ parser = argparse.ArgumentParser(description=__doc__)
146
+ parser.add_argument(
147
+ "command", choices=("sync-version", "verify-version", "test-matrix", "distributions"),
148
+ )
149
+ parser.add_argument("version")
150
+ args = parser.parse_args()
151
+ if args.command == "sync-version":
152
+ sync_version(ROOT, args.version)
153
+ elif args.command == "verify-version":
154
+ verify_version(ROOT, args.version)
155
+ elif args.command == "test-matrix":
156
+ check_test_matrix(ROOT, args.version)
157
+ else:
158
+ check_distributions(ROOT, args.version)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,12 @@
1
+ """Importable sentinels used to check pickle identity."""
2
+
3
+ from compat_sentinel import sentinel
4
+
5
+
6
+ MISSING = sentinel("MISSING")
7
+ CUSTOM = sentinel("CUSTOM", repr="<custom>")
8
+ SAME_NAME = sentinel("MISSING")
9
+
10
+
11
+ class Box:
12
+ SHORT = sentinel("Box.SHORT")
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import os
5
+ from pathlib import Path
6
+ import pickle
7
+ import subprocess
8
+ import sys
9
+ import typing
10
+ import weakref
11
+
12
+ import pytest
13
+
14
+ from compat_sentinel import sentinel
15
+ import sentinel_subjects
16
+
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+
20
+
21
+ def test_each_call_returns_a_new_object() -> None:
22
+ left = sentinel("MISSING")
23
+ right = sentinel("MISSING")
24
+
25
+ assert left is not right
26
+ assert left == left
27
+ assert left != right
28
+ assert (left == "MISSING") is False
29
+
30
+
31
+ def test_repr_name_and_custom_repr() -> None:
32
+ value = sentinel("MISSING")
33
+ custom = sentinel("MISSING", repr="<missing>")
34
+
35
+ assert repr(value) == "MISSING"
36
+ assert str(value) == "MISSING"
37
+ assert repr(custom) == "<missing>"
38
+ assert str(custom) == "<missing>"
39
+ assert value.__name__ == "MISSING"
40
+ assert custom.__name__ == "MISSING"
41
+
42
+
43
+ def test_constructor_rejects_bad_arguments() -> None:
44
+ with pytest.raises(TypeError):
45
+ sentinel("MISSING", "<missing>")
46
+ with pytest.raises(TypeError):
47
+ sentinel(name="MISSING")
48
+ with pytest.raises(TypeError):
49
+ sentinel(1)
50
+ with pytest.raises(TypeError):
51
+ sentinel("MISSING", repr=1)
52
+
53
+
54
+ def test_caller_module_is_recorded() -> None:
55
+ value = sentinel("LOCAL")
56
+
57
+ assert value.__module__ == __name__
58
+
59
+
60
+ def test_attributes_are_closed() -> None:
61
+ value = sentinel("MISSING")
62
+
63
+ with pytest.raises(AttributeError):
64
+ value.__name__ = "OTHER"
65
+ with pytest.raises(AttributeError):
66
+ value.extra = 1
67
+ with pytest.raises(AttributeError):
68
+ del value.__name__
69
+
70
+ value.__module__ = "other.module"
71
+ assert value.__module__ == "other.module"
72
+ with pytest.raises(TypeError):
73
+ value.__module__ = 1
74
+
75
+
76
+ def test_sentinel_is_truthy_and_hashable() -> None:
77
+ value = sentinel("MISSING")
78
+
79
+ assert bool(value) is True
80
+ assert {value: "present"}[value] == "present"
81
+
82
+
83
+ def test_copy_preserves_identity() -> None:
84
+ value = sentinel("MISSING")
85
+
86
+ assert copy.copy(value) is value
87
+ assert copy.deepcopy(value) is value
88
+
89
+
90
+ def test_ordering_and_weakrefs_are_rejected() -> None:
91
+ left = sentinel("LEFT")
92
+ right = sentinel("RIGHT")
93
+
94
+ with pytest.raises(TypeError):
95
+ left < right
96
+ with pytest.raises(TypeError):
97
+ weakref.ref(left)
98
+
99
+
100
+ def test_subclassing_is_rejected() -> None:
101
+ with pytest.raises(TypeError):
102
+
103
+ class Child(sentinel):
104
+ pass
105
+
106
+
107
+ def test_union_accepts_sentinel_on_either_side() -> None:
108
+ value = sentinel("MISSING")
109
+ forward = int | value
110
+ reverse = value | str
111
+
112
+ assert forward == typing.Union[int, value]
113
+ assert reverse == typing.Union[value, str]
114
+ assert value in typing.get_args(forward)
115
+ assert value in typing.get_args(reverse)
116
+
117
+
118
+ def test_module_global_pickle_preserves_identity() -> None:
119
+ restored = pickle.loads(pickle.dumps(sentinel_subjects.MISSING))
120
+
121
+ assert restored is sentinel_subjects.MISSING
122
+
123
+
124
+ def test_custom_repr_survives_pickle() -> None:
125
+ restored = pickle.loads(pickle.dumps(sentinel_subjects.CUSTOM))
126
+
127
+ assert restored is sentinel_subjects.CUSTOM
128
+ assert repr(restored) == "<custom>"
129
+
130
+
131
+ def test_pickle_uses_module_and_name_lookup() -> None:
132
+ blob = pickle.dumps(sentinel_subjects.MISSING)
133
+
134
+ assert b"sentinel_subjects" in blob
135
+ assert b"MISSING" in blob
136
+ assert b"_reconstruct" not in blob
137
+
138
+
139
+ def test_same_name_does_not_share_pickle_identity() -> None:
140
+ assert sentinel_subjects.SAME_NAME is not sentinel_subjects.MISSING
141
+ with pytest.raises(pickle.PicklingError):
142
+ pickle.dumps(sentinel_subjects.SAME_NAME)
143
+
144
+
145
+ def test_class_attribute_pickle_preserves_identity() -> None:
146
+ restored = pickle.loads(pickle.dumps(sentinel_subjects.Box.SHORT))
147
+
148
+ assert restored is sentinel_subjects.Box.SHORT
149
+ assert repr(restored) == "Box.SHORT"
150
+
151
+
152
+ def test_unassigned_sentinel_is_not_picklable() -> None:
153
+ value = sentinel("EPHEMERAL")
154
+
155
+ with pytest.raises(pickle.PicklingError):
156
+ pickle.dumps(value)
157
+
158
+
159
+ def test_writable_module_controls_pickle_lookup() -> None:
160
+ holder = sentinel("HOLDER")
161
+ globals()["HOLDER"] = holder
162
+ try:
163
+ assert pickle.loads(pickle.dumps(holder)) is holder
164
+ holder.__module__ = "not.a.real.module"
165
+ with pytest.raises(pickle.PicklingError):
166
+ pickle.dumps(holder)
167
+ finally:
168
+ del globals()["HOLDER"]
169
+
170
+
171
+ def test_other_process_resolves_the_defining_module() -> None:
172
+ env = os.environ.copy()
173
+ env["PYTHONPATH"] = os.pathsep.join(
174
+ [str(ROOT / "src"), str(ROOT / "tests"), env.get("PYTHONPATH", "")]
175
+ )
176
+ env.pop("PYTHONSAFEPATH", None)
177
+ script = """
178
+ import pickle
179
+ import sys
180
+
181
+ blob = sys.stdin.buffer.read()
182
+ restored = pickle.loads(blob)
183
+ import sentinel_subjects
184
+ print("missing", restored is sentinel_subjects.MISSING)
185
+ print("repr", repr(restored))
186
+ """
187
+ completed = subprocess.run(
188
+ [sys.executable, "-c", script],
189
+ input=pickle.dumps(sentinel_subjects.CUSTOM),
190
+ capture_output=True,
191
+ env=env,
192
+ check=False,
193
+ )
194
+
195
+ assert completed.returncode == 0, completed.stderr.decode()
196
+ assert completed.stdout.decode().splitlines() == [
197
+ "missing False",
198
+ "repr <custom>",
199
+ ]
200
+
201
+ completed = subprocess.run(
202
+ [sys.executable, "-c", script],
203
+ input=pickle.dumps(sentinel_subjects.MISSING),
204
+ capture_output=True,
205
+ env=env,
206
+ check=False,
207
+ )
208
+
209
+ assert completed.returncode == 0, completed.stderr.decode()
210
+ assert completed.stdout.decode().splitlines() == [
211
+ "missing True",
212
+ "repr MISSING",
213
+ ]