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.
- compat_sentinel-0.1.0/.gitignore +12 -0
- compat_sentinel-0.1.0/AGENTS.md +92 -0
- compat_sentinel-0.1.0/LICENSE +21 -0
- compat_sentinel-0.1.0/PKG-INFO +76 -0
- compat_sentinel-0.1.0/README.md +30 -0
- compat_sentinel-0.1.0/RELEASE.md +150 -0
- compat_sentinel-0.1.0/compat_sentinel/__init__.py +15 -0
- compat_sentinel-0.1.0/compat_sentinel/_sentinel.py +98 -0
- compat_sentinel-0.1.0/gearu.toml +34 -0
- compat_sentinel-0.1.0/pyproject.toml +67 -0
- compat_sentinel-0.1.0/scripts/release_checks.py +162 -0
- compat_sentinel-0.1.0/tests/sentinel_subjects.py +12 -0
- compat_sentinel-0.1.0/tests/test_sentinel.py +213 -0
|
@@ -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
|
+
]
|