lib-detect-testenv 3.0.4__tar.gz → 3.0.6__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.
Files changed (44) hide show
  1. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/.gitignore +8 -5
  2. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/CHANGELOG.md +34 -0
  3. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/PKG-INFO +50 -17
  4. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/README.md +38 -8
  5. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/pyproject.toml +31 -11
  6. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/__init__conf__.py +1 -1
  7. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/safe_console.py +9 -4
  8. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/conftest.py +18 -0
  9. lib_detect_testenv-3.0.6/tests/test_packaging.py +127 -0
  10. lib_detect_testenv-3.0.6/tests/test_platform_markers.py +31 -0
  11. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_safe_console.py +25 -0
  12. lib_detect_testenv-3.0.4/.devcontainer/devcontainer.json +0 -22
  13. lib_detect_testenv-3.0.4/.devcontainer/settings.json +0 -6
  14. lib_detect_testenv-3.0.4/.env.example +0 -11
  15. lib_detect_testenv-3.0.4/.github/actions/extract-metadata/action.yml +0 -108
  16. lib_detect_testenv-3.0.4/.github/dependabot.yml +0 -22
  17. lib_detect_testenv-3.0.4/.github/workflows/codeql.yml +0 -39
  18. lib_detect_testenv-3.0.4/.github/workflows/default_cicd_public.yml +0 -436
  19. lib_detect_testenv-3.0.4/.github/workflows/default_release_public.yml +0 -95
  20. lib_detect_testenv-3.0.4/.qlty/qlty.toml +0 -2
  21. lib_detect_testenv-3.0.4/.snyk +0 -5
  22. lib_detect_testenv-3.0.4/CLAUDE.md +0 -177
  23. lib_detect_testenv-3.0.4/CONTRIBUTING.md +0 -48
  24. lib_detect_testenv-3.0.4/DEVELOPMENT.md +0 -134
  25. lib_detect_testenv-3.0.4/INSTALL.md +0 -193
  26. lib_detect_testenv-3.0.4/Makefile +0 -453
  27. lib_detect_testenv-3.0.4/codecov.yml +0 -28
  28. lib_detect_testenv-3.0.4/concept.md +0 -312
  29. lib_detect_testenv-3.0.4/notebooks/Quickstart.ipynb +0 -165
  30. lib_detect_testenv-3.0.4/reset_git_history.sh +0 -55
  31. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/LICENSE +0 -0
  32. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/docs/systemdesign/module_reference.md +0 -0
  33. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/__init__.py +0 -0
  34. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/__main__.py +0 -0
  35. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/behaviors.py +0 -0
  36. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/cli.py +0 -0
  37. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/lib_detect_testenv.py +0 -0
  38. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/py.typed +0 -0
  39. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/src/lib_detect_testenv/typed_click.py +0 -0
  40. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_behaviors.py +0 -0
  41. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_cli.py +0 -0
  42. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_lib_detect_testenv.py +0 -0
  43. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_metadata.py +0 -0
  44. {lib_detect_testenv-3.0.4 → lib_detect_testenv-3.0.6}/tests/test_module_entry.py +0 -0
@@ -47,7 +47,8 @@ tmp_*
47
47
  # *.py[cod]: catches .pyc, .pyo, .pyd
48
48
  __pycache__/
49
49
  *.py[cod]
50
- *$py.class # other compiled artifacts
50
+ # other compiled artifacts
51
+ *$py.class
51
52
 
52
53
  # pytest, coverage, MyPy, Hypothesis
53
54
  .pytest_cache/
@@ -73,12 +74,15 @@ coverage.xml
73
74
  # source/package builds
74
75
  build/
75
76
  dist/
76
- *.egg-info/ # metadata for pip installs
77
+ # metadata for pip installs
78
+ *.egg-info/
77
79
  *.egg
78
80
  *.whl
79
81
  pip-wheel-metadata/
80
- *.manifest # PyInstaller metadata
81
- *.spec # PyInstaller spec files
82
+ # PyInstaller metadata
83
+ *.manifest
84
+ # PyInstaller spec files
85
+ *.spec
82
86
 
83
87
  # Eggs, parts, wheels, sdist, etc.
84
88
  develop-eggs/
@@ -178,7 +182,6 @@ result
178
182
  # Optional lockfiles (for alternative managers)
179
183
  poetry.lock
180
184
  pdm.lock
181
- uv.lock
182
185
 
183
186
  # Local, per-developer settings (Claude Code etc.) - never commit
184
187
  *.local.json
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.6] 2026-10-15 16:35:25
11
+
12
+ ### Fixed
13
+
14
+ - The source distribution ships only the package, its tests, its docs, `README.md`, `LICENSE`,
15
+ `CHANGELOG.md` and `pyproject.toml` (an include list). Earlier sdists also carried every other
16
+ root file and, in a local build, anything hidden only through `.git/info/exclude` or a nested
17
+ `.gitignore`. `tests/test_packaging.py` builds the sdist and wheel and checks both; `hatchling`
18
+ joins the dev extra for it.
19
+
20
+ ### Changed
21
+
22
+ - Ruff selects `C90` (mccabe), so a function over cyclomatic complexity 10, the house hard limit,
23
+ now fails `make test`; before, nothing enforced it.
24
+
25
+ ### Tests
26
+
27
+ - The `os_windows`, `os_macos`, `os_linux` and `os_posix` markers now skip a test off its platform
28
+ (`pytest_runtest_setup` in `tests/conftest.py`); registered in `pyproject.toml` alone they skipped
29
+ nothing, so a test trusting its marker ran everywhere. `tests/test_platform_markers.py` pins it.
30
+
31
+ ## [3.0.5] 2026-10-15 14:11:33
32
+
33
+ ### Added
34
+ - Python 3.15 is a supported version: declared in the classifiers, so CI and `make test-all` run it.
35
+ - `uv.lock` is committed, and `[tool.uv] exclude-newer` sets a cooldown that keeps releases younger
36
+ than it out of the lock. CI installs exactly the locked tree on push and pull request; the nightly
37
+ scheduled run still resolves afresh to catch upstream breakage early.
38
+
39
+ ### Changed
40
+ - The install section explains why installing with uv protects against supply-chain attacks and why
41
+ a plain `pip install` from PyPI does not.
42
+ - `rtoml` floor raised to 0.14.0, the first release with Python 3.15 wheels.
43
+
10
44
  ## [3.0.4] 2026-08-01 00:14:59
11
45
  ### Fixed
12
46
  - **Console output no longer crashes on a legacy codepage.** A Windows console at codepage 1252
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lib_detect_testenv
3
- Version: 3.0.4
3
+ Version: 3.0.6
4
4
  Summary: Detect test environment - pytest, doctest, unittest, or regular execution
5
5
  Project-URL: Homepage, https://github.com/bitranox/lib_detect_testenv
6
6
  Project-URL: Repository, https://github.com/bitranox/lib_detect_testenv.git
@@ -18,24 +18,27 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
21
23
  Classifier: Typing :: Typed
22
24
  Requires-Python: >=3.10
23
- Requires-Dist: rich-click>=1.9.8
25
+ Requires-Dist: rich-click>=1.9.9
24
26
  Provides-Extra: dev
25
27
  Requires-Dist: bandit<1.9,>=1.8.6; (python_version < '3.10') and extra == 'dev'
26
28
  Requires-Dist: bandit>=1.9.4; (python_version >= '3.10') and extra == 'dev'
27
- Requires-Dist: build>=1.5.0; extra == 'dev'
28
- Requires-Dist: click>=8.4.2; extra == 'dev'
29
+ Requires-Dist: build>=1.6.1; extra == 'dev'
30
+ Requires-Dist: click>=8.5.0; extra == 'dev'
31
+ Requires-Dist: hatchling>=1.32.4; extra == 'dev'
29
32
  Requires-Dist: import-linter<2.6,>=2.5.2; (python_version < '3.10') and extra == 'dev'
30
- Requires-Dist: import-linter>=2.13; (python_version >= '3.10') and extra == 'dev'
33
+ Requires-Dist: import-linter>=2.15; (python_version >= '3.10') and extra == 'dev'
31
34
  Requires-Dist: pip-audit>=2.10.1; extra == 'dev'
32
- Requires-Dist: pyright[nodejs]>=1.1.411; extra == 'dev'
35
+ Requires-Dist: pyright[nodejs]>=1.1.414; extra == 'dev'
33
36
  Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
34
37
  Requires-Dist: pytest<9,>=8.4.2; (python_version < '3.10') and extra == 'dev'
35
38
  Requires-Dist: pytest>=9.1.1; (python_version >= '3.10') and extra == 'dev'
36
- Requires-Dist: rtoml>=0.13.0; extra == 'dev'
37
- Requires-Dist: ruff>=0.16.1; extra == 'dev'
38
- Requires-Dist: setuptools>=83.0.0; extra == 'dev'
39
+ Requires-Dist: rtoml>=0.14.0; extra == 'dev'
40
+ Requires-Dist: ruff>=0.17.0; extra == 'dev'
41
+ Requires-Dist: setuptools>=84.0.0; extra == 'dev'
39
42
  Requires-Dist: textual>=8.2.8; extra == 'dev'
40
43
  Requires-Dist: twine>=7.0.0; extra == 'dev'
41
44
  Description-Content-Type: text/markdown
@@ -72,7 +75,7 @@ This library provides utility functions to detect whether your code is running i
72
75
 
73
76
  ### Recommended: Install via UV
74
77
 
75
- UV is an ultrafast Python package installer written in Rust (10–20× faster than pip/poetry):
78
+ UV is an ultrafast Python package installer written in Rust (10-20× faster than pip/poetry):
76
79
 
77
80
  ```bash
78
81
  # Install uv
@@ -107,9 +110,39 @@ For more installation methods (pipx, source builds, etc.), see [INSTALL.md](INST
107
110
 
108
111
  ### Python Version Requirements
109
112
 
110
- - **Python 3.9+** is required
113
+ - **Python 3.10+** is required
111
114
  - Tested on Linux, macOS, and Windows
112
- - CI runs on CPython 3.9+ and PyPy
115
+ - CI runs on CPython 3.10 to 3.15
116
+
117
+ ### Why we recommend uv: supply-chain protection
118
+
119
+ This repository commits a `uv.lock`. It pins every package the project needs, transitive
120
+ dependencies included, to an exact version and to the hashes of that version's files. The
121
+ `exclude-newer` setting under `[tool.uv]` in `pyproject.toml` adds a cooldown: uv leaves any
122
+ release younger than that out of the lock. CI installs from the lock, so a hijacked release that
123
+ gets pulled again within the cooldown never reaches our builds, and a file whose hash does not
124
+ match is refused.
125
+
126
+ That protection stays in this repository. The installable package declares only lower bounds
127
+ (`>=`) for its dependencies, so a plain `pip install` takes the newest release of every transitive
128
+ dependency at the moment you run it. If one of them was hijacked an hour earlier, you get the
129
+ hijacked version. `pipx install` and `uv tool install` without options do the same.
130
+
131
+ To install the command line tool with a cooldown of your own (uv then skips any release younger
132
+ than 7 days, transitive ones included), or to run the exact tree our CI tests:
133
+
134
+ ```bash
135
+ uv tool install --exclude-newer "7 days" lib_detect_testenv
136
+ git clone https://github.com/bitranox/lib_detect_testenv.git && cd lib_detect_testenv && uv sync --locked && uv run lib_detect_testenv --help
137
+ ```
138
+
139
+ If you depend on lib_detect_testenv in your own project, give that project the same cooldown (7 days here, as
140
+ an example) and commit the `uv.lock` that `uv add lib_detect_testenv` writes:
141
+
142
+ ```toml
143
+ [tool.uv]
144
+ exclude-newer = "7 days"
145
+ ```
113
146
 
114
147
  ## Usage
115
148
 
@@ -121,11 +154,11 @@ The package provides a CLI for shell scripting and CI/CD integration. All comman
121
154
 
122
155
  All detection commands use a **3-level exit code system**:
123
156
 
124
- | Code | Meaning | Use Case |
125
- |------|---------|----------|
126
- | **0** | Test environment **detected** | Success - test env found |
127
- | **1** | Test environment **not detected** | Normal negative result (not an error) |
128
- | **2** | **Error occurred** | Command failed (bad arguments, exception) |
157
+ | Code | Meaning | Use Case |
158
+ |-------|-----------------------------------|-------------------------------------------|
159
+ | **0** | Test environment **detected** | Success - test env found |
160
+ | **1** | Test environment **not detected** | Normal negative result (not an error) |
161
+ | **2** | **Error occurred** | Command failed (bad arguments, exception) |
129
162
 
130
163
  This design follows Unix conventions and makes shell scripting intuitive:
131
164
 
@@ -30,7 +30,7 @@ This library provides utility functions to detect whether your code is running i
30
30
 
31
31
  ### Recommended: Install via UV
32
32
 
33
- UV is an ultrafast Python package installer written in Rust (10–20× faster than pip/poetry):
33
+ UV is an ultrafast Python package installer written in Rust (10-20× faster than pip/poetry):
34
34
 
35
35
  ```bash
36
36
  # Install uv
@@ -65,9 +65,39 @@ For more installation methods (pipx, source builds, etc.), see [INSTALL.md](INST
65
65
 
66
66
  ### Python Version Requirements
67
67
 
68
- - **Python 3.9+** is required
68
+ - **Python 3.10+** is required
69
69
  - Tested on Linux, macOS, and Windows
70
- - CI runs on CPython 3.9+ and PyPy
70
+ - CI runs on CPython 3.10 to 3.15
71
+
72
+ ### Why we recommend uv: supply-chain protection
73
+
74
+ This repository commits a `uv.lock`. It pins every package the project needs, transitive
75
+ dependencies included, to an exact version and to the hashes of that version's files. The
76
+ `exclude-newer` setting under `[tool.uv]` in `pyproject.toml` adds a cooldown: uv leaves any
77
+ release younger than that out of the lock. CI installs from the lock, so a hijacked release that
78
+ gets pulled again within the cooldown never reaches our builds, and a file whose hash does not
79
+ match is refused.
80
+
81
+ That protection stays in this repository. The installable package declares only lower bounds
82
+ (`>=`) for its dependencies, so a plain `pip install` takes the newest release of every transitive
83
+ dependency at the moment you run it. If one of them was hijacked an hour earlier, you get the
84
+ hijacked version. `pipx install` and `uv tool install` without options do the same.
85
+
86
+ To install the command line tool with a cooldown of your own (uv then skips any release younger
87
+ than 7 days, transitive ones included), or to run the exact tree our CI tests:
88
+
89
+ ```bash
90
+ uv tool install --exclude-newer "7 days" lib_detect_testenv
91
+ git clone https://github.com/bitranox/lib_detect_testenv.git && cd lib_detect_testenv && uv sync --locked && uv run lib_detect_testenv --help
92
+ ```
93
+
94
+ If you depend on lib_detect_testenv in your own project, give that project the same cooldown (7 days here, as
95
+ an example) and commit the `uv.lock` that `uv add lib_detect_testenv` writes:
96
+
97
+ ```toml
98
+ [tool.uv]
99
+ exclude-newer = "7 days"
100
+ ```
71
101
 
72
102
  ## Usage
73
103
 
@@ -79,11 +109,11 @@ The package provides a CLI for shell scripting and CI/CD integration. All comman
79
109
 
80
110
  All detection commands use a **3-level exit code system**:
81
111
 
82
- | Code | Meaning | Use Case |
83
- |------|---------|----------|
84
- | **0** | Test environment **detected** | Success - test env found |
85
- | **1** | Test environment **not detected** | Normal negative result (not an error) |
86
- | **2** | **Error occurred** | Command failed (bad arguments, exception) |
112
+ | Code | Meaning | Use Case |
113
+ |-------|-----------------------------------|-------------------------------------------|
114
+ | **0** | Test environment **detected** | Success - test env found |
115
+ | **1** | Test environment **not detected** | Normal negative result (not an error) |
116
+ | **2** | **Error occurred** | Command failed (bad arguments, exception) |
87
117
 
88
118
  This design follows Unix conventions and makes shell scripting intuitive:
89
119
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "lib_detect_testenv"
3
- version = "3.0.4"
3
+ version = "3.0.6"
4
4
  description = "Detect test environment - pytest, doctest, unittest, or regular execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
- "rich-click>=1.9.8",
8
+ "rich-click>=1.9.9",
9
9
  ]
10
10
  license = { text = "MIT" }
11
11
  authors = [{ name = "bitranox", email = "bitranox@gmail.com" }]
@@ -17,6 +17,8 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3.11",
18
18
  "Programming Language :: Python :: 3.12",
19
19
  "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
20
22
  "License :: OSI Approved :: MIT License",
21
23
  "Environment :: Console",
22
24
  "Operating System :: OS Independent",
@@ -33,13 +35,12 @@ dev = [
33
35
  "pytest>=8.4.2,<9; python_version<'3.10'",
34
36
  "pytest>=9.1.1; python_version>='3.10'",
35
37
  "pytest-cov>=7.1.0",
36
- "ruff>=0.16.1",
37
- "pyright[nodejs]>=1.1.411",
38
+ "ruff>=0.17.0",
39
+ "pyright[nodejs]>=1.1.414",
38
40
  "bandit>=1.8.6,<1.9; python_version<'3.10'",
39
41
  "bandit>=1.9.4; python_version>='3.10'",
40
- # 1.5.1 was yanked upstream (unintended breaking changes), so 1.5.0 is the newest usable
41
- # release; a >=1.5.1 floor has ZERO candidates and makes this extra unresolvable.
42
- "build>=1.5.0",
42
+ "build>=1.6.1",
43
+ "hatchling>=1.32.4",
43
44
  "twine>=7.0.0",
44
45
  # codecov-cli is disabled, not dropped: it requires click<8.3.0, which drags click back to
45
46
  # 8.2.1 - the version with CVE-2026-7246 (command injection in click.edit(), fixed in 8.3.3).
@@ -49,13 +50,13 @@ dev = [
49
50
  # Re-enable once codecov-cli relaxes its click<8.3.0 pin.
50
51
  # "codecov-cli>=11.2.8",
51
52
  # Transitive CVE pins
52
- "click>=8.4.2", # CVE-2026-7246
53
+ "click>=8.5.0", # CVE-2026-7246
53
54
  "pip-audit>=2.10.1",
54
55
  "textual>=8.2.8",
55
56
  "import-linter>=2.5.2,<2.6; python_version<'3.10'",
56
- "import-linter>=2.13; python_version>='3.10'",
57
- "setuptools>=83.0.0",
58
- "rtoml>=0.13.0",
57
+ "import-linter>=2.15; python_version>='3.10'",
58
+ "setuptools>=84.0.0",
59
+ "rtoml>=0.14.0",
59
60
  ]
60
61
 
61
62
  [project.scripts]
@@ -71,6 +72,21 @@ build-backend = "hatchling.build"
71
72
  packages = ["src/lib_detect_testenv"]
72
73
  include = ["src/lib_detect_testenv/py.typed"]
73
74
 
75
+ # An allowlist, never a denylist: hatchling reads only the root .gitignore, so tracked working
76
+ # files and files hidden through .git/info/exclude or a nested .gitignore (session records,
77
+ # review notes, helper scripts) would ship. tests/test_packaging.py builds the sdist and
78
+ # refuses any other top-level entry.
79
+ [tool.hatch.build.targets.sdist]
80
+ include = [
81
+ "/src/lib_detect_testenv",
82
+ "/tests/*.py",
83
+ "/docs",
84
+ "/README.md",
85
+ "/LICENSE",
86
+ "/CHANGELOG.md",
87
+ "/pyproject.toml",
88
+ ]
89
+
74
90
  [tool.ruff]
75
91
  line-length = 160
76
92
  target-version = "py39"
@@ -85,6 +101,7 @@ select = [
85
101
  "B", # bugbear
86
102
  "A", # builtins shadowing
87
103
  "C4", # comprehension simplification
104
+ "C90", # mccabe: cyclomatic complexity <= 10 (the house hard limit)
88
105
  "SIM", # simplification
89
106
  "TCH", # type-checking imports
90
107
  "RUF", # ruff-specific
@@ -200,3 +217,6 @@ ignore-vulns = ["GHSA-4xh5-x5gv-qwph", "PYSEC-2022-42969", "CVE-2026-25990", "CV
200
217
  [tool.bashate]
201
218
  max-line-length = 120
202
219
  ignores = ["E003"]
220
+
221
+ [tool.uv]
222
+ exclude-newer = "1 day"
@@ -26,7 +26,7 @@ name = "lib_detect_testenv"
26
26
  #: Human-readable summary shown in CLI help output.
27
27
  title = "Detect test environment - pytest, doctest, unittest, or regular execution"
28
28
  #: Current release version pulled from ``pyproject.toml`` by automation.
29
- version = "3.0.4"
29
+ version = "3.0.6"
30
30
  #: Repository homepage presented to users.
31
31
  homepage = "https://github.com/bitranox/lib_detect_testenv"
32
32
  #: Author attribution surfaced in CLI output.
@@ -64,13 +64,19 @@ ASCII_FALLBACKS: Final[dict[str, str]] = {
64
64
  _UNIVERSAL_ENCODINGS: Final[frozenset[str]] = frozenset({"utf-8", "utf8", "utf-16", "utf16", "utf-32", "utf32"})
65
65
 
66
66
 
67
- def _stream_encoding(file: IO[Any] | None) -> str | None:
67
+ def _stream_encoding(file: IO[Any] | None, *, err: bool = False) -> str | None:
68
68
  """Return the target stream's encoding, or None when it cannot be determined.
69
69
 
70
70
  An unknown encoding means the caller gets the original text: guessing would
71
71
  degrade output that may well have been fine.
72
+
73
+ With no explicit `file` the answer comes from ``sys.stdout``/``sys.stderr``,
74
+ which is what :func:`click.echo` resolves its own default target from. click
75
+ exposes no supported way to ask for that stream: ``get_text_stream`` was
76
+ deprecated in click 8.5.0 and is removed in 9.0, its documented replacement
77
+ being to let ``echo`` resolve the stream itself.
72
78
  """
73
- stream = file if file is not None else click.get_text_stream("stdout")
79
+ stream = file if file is not None else (sys.stderr if err else sys.stdout)
74
80
  encoding = getattr(stream, "encoding", None)
75
81
  return encoding if isinstance(encoding, str) else None
76
82
 
@@ -135,8 +141,7 @@ def echo(message: object = "", *, file: IO[Any] | None = None, err: bool = False
135
141
  Writes to the given stream.
136
142
  """
137
143
  text = message if isinstance(message, str) else str(message)
138
- target = file if file is not None else click.get_text_stream("stderr" if err else "stdout")
139
- click.echo(encode_safe(text, _stream_encoding(target)), file=target, nl=nl)
144
+ click.echo(encode_safe(text, _stream_encoding(file, err=err)), file=file, err=err, nl=nl)
140
145
 
141
146
 
142
147
  class _SafeWriter:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ import sys
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  import pytest
@@ -48,3 +49,20 @@ def strip_ansi() -> Callable[[str], str]:
48
49
  return _remove_ansi_codes(value)
49
50
 
50
51
  return _strip
52
+
53
+
54
+ #: Which platforms each ``os_*`` marker lets a test run on. Registering a marker in pyproject only
55
+ #: silences pytest's unknown-marker warning; without this table a marked test runs everywhere.
56
+ _PLATFORM_MARKERS: dict[str, Callable[[str], bool]] = {
57
+ "os_windows": lambda platform: platform == "win32",
58
+ "os_macos": lambda platform: platform == "darwin",
59
+ "os_linux": lambda platform: platform.startswith("linux"),
60
+ "os_posix": lambda platform: platform != "win32",
61
+ }
62
+
63
+
64
+ def pytest_runtest_setup(item: pytest.Item) -> None:
65
+ """Skip a test whose ``os_*`` marker names a platform other than the running one."""
66
+ for name, runs_on in _PLATFORM_MARKERS.items():
67
+ if item.get_closest_marker(name) is not None and not runs_on(sys.platform):
68
+ pytest.skip(f"marked {name}; this is {sys.platform}")
@@ -0,0 +1,127 @@
1
+ """The source distribution carries the package, its tests and its docs, and nothing else.
2
+
3
+ A working tree holds files that must never be published: session records, backlogs, scratch
4
+ review notes and helper scripts. Some are tracked, some are hidden only through
5
+ ``.git/info/exclude`` or a nested ``.gitignore``, which hatchling does not read. The sdist is
6
+ therefore built from an allowlist, and this test builds one from a copy of the tree that also
7
+ holds such decoys. The wheel must still carry the package data the runtime loads.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import tarfile
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+ from hatchling.builders.sdist import SdistBuilder
19
+ from hatchling.builders.wheel import WheelBuilder
20
+
21
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
22
+ PACKAGE = "lib_detect_testenv"
23
+
24
+ # hatchling force-includes the root .gitignore into every sdist (so a build from the unpacked
25
+ # sdist applies the same exclusions); no include/exclude setting removes it.
26
+ ALLOWED_TOP_LEVEL = frozenset(
27
+ {
28
+ "PKG-INFO",
29
+ "pyproject.toml",
30
+ "README.md",
31
+ "LICENSE",
32
+ "CHANGELOG.md",
33
+ ".gitignore",
34
+ "src",
35
+ "tests",
36
+ "docs",
37
+ }
38
+ )
39
+ REQUIRED_TOP_LEVEL = frozenset({"PKG-INFO", "pyproject.toml", "src", "tests"})
40
+
41
+ # Files and directories that exist in working trees and must never ship. Tracked ones are
42
+ # named in the repository; the others are hidden locally through .git/info/exclude.
43
+ DECOY_FILES = ("handover.md", "OPEN-WORK.md", "EXECUTION-USER-REVIEW.md", "reset_git_history.sh", ".private/notes.txt")
44
+
45
+ REQUIRED_WHEEL_DATA = ("lib_detect_testenv/py.typed",)
46
+
47
+
48
+ def _copy_tree_with_decoys(destination: Path) -> Path:
49
+ """Copy the buildable tree and plant the files a working tree carries but must not ship."""
50
+ root = destination / "tree"
51
+ root.mkdir()
52
+ for name in ("pyproject.toml", "README.md", "LICENSE", "CHANGELOG.md", ".gitignore"):
53
+ shutil.copy2(PROJECT_ROOT / name, root / name)
54
+ for name in ("src", "docs", "tests"):
55
+ shutil.copytree(PROJECT_ROOT / name, root / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
56
+ for decoy in DECOY_FILES:
57
+ path = root / decoy
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text("decoy\n", encoding="utf-8")
60
+ return root
61
+
62
+
63
+ def _build_sdist(root: Path, directory: Path) -> Path:
64
+ built = list(SdistBuilder(str(root)).build(directory=str(directory), versions=["standard"]))
65
+ assert len(built) == 1, f"expected one sdist, got {built}"
66
+ return Path(built[0])
67
+
68
+
69
+ def _strip_root(names: list[str]) -> list[str]:
70
+ """Drop the ``<name>-<version>/`` prefix every member carries."""
71
+ return [name.split("/", 1)[1] for name in names if "/" in name]
72
+
73
+
74
+ @pytest.fixture(scope="module")
75
+ def built_tree(tmp_path_factory: pytest.TempPathFactory) -> Path:
76
+ return _copy_tree_with_decoys(tmp_path_factory.mktemp("packaging"))
77
+
78
+
79
+ @pytest.fixture(scope="module")
80
+ def sdist_members(built_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> list[str]:
81
+ sdist = _build_sdist(built_tree, tmp_path_factory.mktemp("sdist"))
82
+ with tarfile.open(sdist, "r:gz") as archive:
83
+ members = _strip_root(archive.getnames())
84
+ assert members, "the sdist is empty, so nothing below would be judged"
85
+ return members
86
+
87
+
88
+ @pytest.fixture(scope="module")
89
+ def wheel_members(built_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> list[str]:
90
+ built = list(WheelBuilder(str(built_tree)).build(directory=str(tmp_path_factory.mktemp("wheel")), versions=["standard"]))
91
+ assert len(built) == 1, f"expected one wheel, got {built}"
92
+ with zipfile.ZipFile(built[0]) as archive:
93
+ members = archive.namelist()
94
+ assert members, "the wheel is empty, so nothing below would be judged"
95
+ return members
96
+
97
+
98
+ @pytest.mark.os_agnostic
99
+ def test_the_sdist_holds_only_allowlisted_top_level_entries(sdist_members: list[str]) -> None:
100
+ top_level = {member.split("/", 1)[0] for member in sdist_members}
101
+
102
+ unexpected = sorted(top_level - ALLOWED_TOP_LEVEL)
103
+ missing = sorted(REQUIRED_TOP_LEVEL - top_level)
104
+
105
+ assert not unexpected, f"the sdist ships files outside the allowlist: {unexpected}"
106
+ assert not missing, f"the sdist lacks required entries: {missing}"
107
+
108
+
109
+ @pytest.mark.os_agnostic
110
+ def test_the_sdist_ships_only_the_package_under_src(sdist_members: list[str]) -> None:
111
+ under_src = {member.split("/")[1] for member in sdist_members if member.startswith("src/") and member.count("/") >= 1}
112
+
113
+ assert under_src == {PACKAGE}
114
+
115
+
116
+ @pytest.mark.os_agnostic
117
+ def test_the_sdist_carries_no_compiled_bytecode(sdist_members: list[str]) -> None:
118
+ compiled = [member for member in sdist_members if "__pycache__" in member or member.endswith(".pyc")]
119
+
120
+ assert not compiled, f"the sdist ships bytecode: {compiled[:5]}"
121
+
122
+
123
+ @pytest.mark.os_agnostic
124
+ def test_the_wheel_carries_the_package_data_the_runtime_loads(wheel_members: list[str]) -> None:
125
+ missing = [name for name in REQUIRED_WHEEL_DATA if name not in wheel_members]
126
+
127
+ assert not missing, f"the wheel lacks package data: {missing}"
@@ -0,0 +1,31 @@
1
+ """The ``os_*`` markers skip a test off its platform; registering them alone would skip nothing.
2
+
3
+ Each test asserts the platform its own marker names, so it fails wherever conftest lets it run by
4
+ mistake, and passes where the marker lets it run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ import pytest
12
+
13
+
14
+ @pytest.mark.os_windows
15
+ def test_an_os_windows_test_runs_only_on_windows() -> None:
16
+ assert sys.platform == "win32"
17
+
18
+
19
+ @pytest.mark.os_macos
20
+ def test_an_os_macos_test_runs_only_on_macos() -> None:
21
+ assert sys.platform == "darwin"
22
+
23
+
24
+ @pytest.mark.os_linux
25
+ def test_an_os_linux_test_runs_only_on_linux() -> None:
26
+ assert sys.platform.startswith("linux")
27
+
28
+
29
+ @pytest.mark.os_posix
30
+ def test_an_os_posix_test_never_runs_on_windows() -> None:
31
+ assert sys.platform != "win32"
@@ -8,6 +8,7 @@ keeps the next output line someone adds covered by construction.
8
8
  from __future__ import annotations
9
9
 
10
10
  import io
11
+ import sys
11
12
  from pathlib import Path
12
13
 
13
14
  import pytest
@@ -78,6 +79,30 @@ class TestEchoOnAUtf8Console:
78
79
  assert "\u2713" in buffer.getvalue().decode("utf-8")
79
80
 
80
81
 
82
+ class TestTheDefaultTargetFollowsTheStreamEchoWritesTo:
83
+ """With no `file`, the encoding must come from the stream `echo` lands on.
84
+
85
+ click resolves that target itself from ``sys.stdout``/``sys.stderr``. Judge
86
+ the wrong stream and a legacy-codepage console gets exactly the crash this
87
+ module exists to prevent, while every test passing an explicit `file` stays
88
+ green and says nothing about it.
89
+ """
90
+
91
+ def test_a_cp1252_stdout_degrades_the_glyph(self, monkeypatch: pytest.MonkeyPatch) -> None:
92
+ stream = _cp1252_stream()
93
+ monkeypatch.setattr(sys, "stdout", stream)
94
+ safe_console.echo("✓ deployed")
95
+ assert "[OK]" in _read_back(stream)
96
+
97
+ def test_err_is_judged_against_stderr_not_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None:
98
+ """A stdout that could take the glyph must not excuse a cp1252 stderr."""
99
+ stream = _cp1252_stream()
100
+ monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(io.BytesIO(), encoding="utf-8", newline=""))
101
+ monkeypatch.setattr(sys, "stderr", stream)
102
+ safe_console.echo("✓ deployed", err=True)
103
+ assert "[OK]" in _read_back(stream)
104
+
105
+
81
106
  class TestSafeStreamProtectsRich:
82
107
  """Rich raises on a legacy codepage too; it renders through its own writer."""
83
108
 
@@ -1,22 +0,0 @@
1
- {
2
- "image": "mcr.microsoft.com/devcontainers/python:3.13",
3
-
4
- "postCreateCommand": "bash -lc '\nPY=/usr/local/bin/python;\n$PY -m pip install -U pip && \\\n$PY -m pip install ipykernel && \\\n$PY -m pip install -e . && \\\n# register kernel using this exact interpreter\n$PY -m ipykernel install --name=python3 --display-name=\"Python 3 (3.13)\" --user && \\\n# patch the notebook to point to the python3 kernelspec (VS Code will bind to /usr/local/bin/python)\n$PY - <<\"PY\"\nimport json, pathlib\np = pathlib.Path(\"notebooks/Quickstart.ipynb\")\nif p.exists():\n nb = json.loads(p.read_text(encoding=\"utf-8\"))\n md = nb.setdefault(\"metadata\", {})\n md[\"kernelspec\"] = {\n \"name\": \"python3\",\n \"display_name\": \"Python 3 (3.13)\",\n \"language\": \"python\"\n }\n md[\"language_info\"] = {\"name\": \"python\"}\n p.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding=\"utf-8\")\n print(\"Pinned kernelspec to python3 in\", p)\nelse:\n print(\"Notebook not found:\", p)\nPY'\n",
5
-
6
- "customizations": {
7
- "vscode": {
8
- "extensions": ["ms-toolsai.jupyter", "ms-python.python"],
9
- "settings": {
10
- "workbench.startupEditor": "none",
11
- "jupyter.alwaysTrustNotebooks": true,
12
- "jupyter.kernelPickerType": "OnlyRecommended",
13
- "python.defaultInterpreterPath": "/usr/local/bin/python"
14
- }
15
- },
16
- "codespaces": {
17
- "openFiles": ["README.md"]
18
- }
19
- },
20
-
21
- "postAttachCommand": "bash -lc 'if [ -f notebooks/Quickstart.ipynb ]; then code -r notebooks/Quickstart.ipynb; fi'"
22
- }
@@ -1,6 +0,0 @@
1
- {
2
- "python.defaultInterpreterPath": "/usr/local/bin/python3.13",
3
- "jupyter.alwaysTrustNotebooks": true,
4
- "jupyter.kernelPickerType": "OnlyRecommended",
5
- "jupyter.askForKernelRestart": false
6
- }
@@ -1,11 +0,0 @@
1
- # Copy to .env and fill values as needed.
2
-
3
- # Codecov upload token (required for private repos; optional for public repos)
4
- CODECOV_TOKEN=
5
-
6
- # PyPI API token for release workflow (.github/workflows/release.yml)
7
- # Format: pypi-AgENdGVzdC5weXBpLm9yZwIk...
8
- PYPI_API_TOKEN=
9
-
10
- # Optional: GitHub token for API-limited tasks
11
- GITHUB_TOKEN=