lib-cli-exit-tools 2.0.0__tar.gz → 2.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.
Files changed (76) hide show
  1. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/AGENTS.md +2 -2
  2. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/CHANGELOG.md +7 -0
  3. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/DEVELOPMENT.md +4 -4
  4. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/INSTALL.md +2 -2
  5. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/PKG-INFO +10 -9
  6. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/README.md +9 -8
  7. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/docs/system-design/module_reference.md +5 -5
  8. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/notebooks/Quickstart.ipynb +1 -1
  9. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/pyproject.toml +1 -1
  10. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/_utils.py +151 -9
  11. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/build.py +2 -1
  12. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/push.py +8 -1
  13. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/test.py +17 -23
  14. lib_cli_exit_tools-2.1.0/src/lib_cli_exit_tools/__init__conf__.py +67 -0
  15. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/cli.py +1 -1
  16. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_cli.py +12 -0
  17. lib_cli_exit_tools-2.1.0/tests/test_metadata.py +47 -0
  18. lib_cli_exit_tools-2.0.0/src/lib_cli_exit_tools/__init__conf__.py +0 -220
  19. lib_cli_exit_tools-2.0.0/tests/test_metadata.py +0 -101
  20. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.codacy/cli-config.yaml +0 -0
  21. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.codacy/cli.sh +0 -0
  22. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.codacy/codacy.yaml +0 -0
  23. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.codacy/tools-configs/lizard.yaml +0 -0
  24. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.codacy/tools-configs/trivy.yaml +0 -0
  25. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.devcontainer/devcontainer.json +0 -0
  26. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.devcontainer/settings.json +0 -0
  27. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.env.example +0 -0
  28. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.github/dependabot.yml +0 -0
  29. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.github/workflows/ci.yml +0 -0
  30. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.github/workflows/codeql.yml +0 -0
  31. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.github/workflows/release.yml +0 -0
  32. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.gitignore +0 -0
  33. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.qlty/qlty.toml +0 -0
  34. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/.snyk +0 -0
  35. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/AGENTS_README.md +0 -0
  36. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/CONTRIBUTING.md +0 -0
  37. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/LICENSE +0 -0
  38. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/Makefile +0 -0
  39. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/codecov.yml +0 -0
  40. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/__init__.py +0 -0
  41. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/__main__.py +0 -0
  42. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/bump.py +0 -0
  43. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/bump_major.py +0 -0
  44. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/bump_minor.py +0 -0
  45. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/bump_patch.py +0 -0
  46. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/bump_version.py +0 -0
  47. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/clean.py +0 -0
  48. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/cli.py +0 -0
  49. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/dev.py +0 -0
  50. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/help.py +0 -0
  51. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/install.py +0 -0
  52. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/menu.py +0 -0
  53. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/release.py +0 -0
  54. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/run_cli.py +0 -0
  55. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/target_metadata.py +0 -0
  56. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/scripts/version_current.py +0 -0
  57. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/__init__.py +0 -0
  58. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/__main__.py +0 -0
  59. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/adapters/__init__.py +0 -0
  60. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/adapters/signals.py +0 -0
  61. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/application/__init__.py +0 -0
  62. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/application/runner.py +0 -0
  63. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/core/__init__.py +0 -0
  64. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/core/configuration.py +0 -0
  65. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/core/exit_codes.py +0 -0
  66. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/lib_cli_exit_tools.py +0 -0
  67. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/src/lib_cli_exit_tools/py.typed +0 -0
  68. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/conftest.py +0 -0
  69. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_adapters_signals.py +0 -0
  70. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_application_runner.py +0 -0
  71. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_behaviors.py +0 -0
  72. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_core_configuration.py +0 -0
  73. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_core_exit_codes.py +0 -0
  74. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_module_entry.py +0 -0
  75. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_scripts.py +0 -0
  76. {lib_cli_exit_tools-2.0.0 → lib_cli_exit_tools-2.1.0}/tests/test_signals_integration.py +0 -0
@@ -50,8 +50,8 @@ when writing or refracturing Python scripts, apply those Rules :
50
50
  ### Versioning & Releases
51
51
 
52
52
  - Single source of truth for the package version is `pyproject.toml` (`[project].version`).
53
- - Runtime code reads metadata via `importlib.metadata`; do not duplicate the version in code files.
54
- - On a version bump, update only `pyproject.toml` and the `CHANGELOG.md` entry; runtime code should continue to read the version via `importlib.metadata` (no separate `__init__conf__` file).
53
+ - Automation rewrites `src/lib_cli_exit_tools/__init__conf__.py` from `pyproject.toml`, so runtime code imports generated constants instead of querying `importlib.metadata`.
54
+ - After updating project metadata (version, summary, URLs, authors) run `make test` (or `python -m scripts.test`) to regenerate the metadata module before committing.
55
55
  - Tag releases `vX.Y.Z` and push tags; CI will build artifacts and publish when configured.
56
56
 
57
57
  ### Common Make Targets (Alphabetical)
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
5
 
6
+ ## [2.1.0] - 2025-10-13
7
+
8
+ ### Changed
9
+ - `scripts.build` now calls `sync_metadata_module()` before invoking `python -m build`, ensuring wheels and sdists ship freshly generated metadata.
10
+ - Added an explicit CLI test covering the `--version` flag so console-script aliases stay aligned with generated constants.
11
+ - Updated README, INSTALL instructions, and the Quickstart notebook to document `lib-cli-exit-tools` as the canonical console entry point alongside its aliases.
12
+
6
13
  ## [2.0.0] - 2025-10-12
7
14
 
8
15
  ### Changed
@@ -118,10 +118,10 @@ keep the signal-handling guarantees honest.
118
118
 
119
119
  ## Versioning & Metadata
120
120
 
121
- - Single source of truth for package metadata is `pyproject.toml` (`[project]`).
122
- - The library reads its own installed metadata at runtime via `importlib.metadata` (see `src/lib_cli_exit_tools/__init__conf__.py`).
123
- - Do not duplicate the version in code; bump only `pyproject.toml` and update `CHANGELOG.md`.
124
- - Console script name is discovered from entry points; defaults to `lib_cli_exit_tools`.
121
+ - `pyproject.toml` (`[project]`) remains the single source of truth for name, version, homepage, and authors.
122
+ - `scripts._utils.sync_metadata_module()` rewrites `src/lib_cli_exit_tools/__init__conf__.py` so runtime code consumes generated constants instead of `importlib.metadata`.
123
+ - After editing project metadata (name, version, description, URLs, authors), run `make test` or `python -m scripts.test` to regenerate the module before committing.
124
+ - Console script name is still discovered from entry points; automation falls back to `lib_cli_exit_tools` when no explicit alias exists.
125
125
 
126
126
  ## CI & Publishing
127
127
 
@@ -85,7 +85,7 @@ pipx install "git+https://github.com/bitranox/lib_cli_exit_tools@vX.Y.Z"
85
85
  ```bash
86
86
  uv pip install -e .[dev]
87
87
  uv tool install lib_cli_exit_tools
88
- uvx lib_cli_exit_tools --help
88
+ uvx lib-cli-exit-tools --help # aliases: cli-exit-tools, lib_cli_exit_tools
89
89
  ```
90
90
 
91
91
  ### Installing from local artifacts
@@ -153,7 +153,7 @@ error: externally-managed-environment
153
153
 
154
154
  ### Missing command on PATH
155
155
 
156
- If `lib_cli_exit_tools --help` returns “command not found”, confirm that the installation location is on PATH. The most common fix is adding `~/.local/bin` (POSIX) or `%APPDATA%\Python\PythonXY\Scripts` (Windows) to your PATH environment variable, then re-opening the shell.
156
+ If `lib-cli-exit-tools --help` (or the aliases `cli-exit-tools` / `lib_cli_exit_tools`) returns “command not found”, confirm that the installation location is on PATH. The most common fix is adding `~/.local/bin` (POSIX) or `%APPDATA%\Python\PythonXY\Scripts` (Windows) to your PATH environment variable, then re-opening the shell.
157
157
 
158
158
  ### Colour output not appearing
159
159
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lib_cli_exit_tools
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: CLI exit handling helpers: clean signals, exit codes, and error printing
5
5
  Project-URL: Homepage, https://github.com/bitranox/lib_cli_exit_tools
6
6
  Project-URL: Repository, https://github.com/bitranox/lib_cli_exit_tools.git
@@ -65,14 +65,14 @@ See [INSTALL.md](INSTALL.md) for editable installs, pipx/uv usage, and troublesh
65
65
 
66
66
  ## Usage
67
67
 
68
- Console script:
68
+ Console script (all commands map to ``lib_cli_exit_tools.cli:main``):
69
69
 
70
70
  ```bash
71
71
  # After install (pip/pipx/uv tool)
72
- lib_cli_exit_tools --help
73
- cli-exit-tools --help # alias
74
- lib_cli_exit_tools info
75
- lib_cli_exit_tools fail # intentionally trigger RuntimeError to test error paths
72
+ lib-cli-exit-tools --help
73
+ lib-cli-exit-tools info
74
+ lib-cli-exit-tools fail # intentionally trigger RuntimeError to test error paths
75
+ # Aliases are also generated: `cli-exit-tools`, `lib_cli_exit_tools`
76
76
  ```
77
77
 
78
78
  ### Examples
@@ -280,9 +280,10 @@ except Exception as e:
280
280
  raise SystemExit(code)
281
281
  ```
282
282
 
283
- Command names registered on install
284
- - lib_cli_exit_tools (default)
283
+ Command names registered on install (all invoke ``lib_cli_exit_tools.cli:main``)
284
+ - lib-cli-exit-tools (default console script)
285
285
  - cli-exit-tools (alias)
286
+ - lib_cli_exit_tools (alias)
286
287
  - python -m lib_cli_exit_tools (module entry)
287
288
 
288
289
  If you installed with --user or in a venv, make sure the corresponding bin directory is on PATH:
@@ -562,7 +563,7 @@ def main(argv: Sequence[str] | None = None) -> int:
562
563
  )
563
564
  ```
564
565
 
565
- When installed, the generated console scripts (`lib_cli_exit_tools`, `cli-exit-tools`, `lib-cli-exit-tools`) will import `your_package.cli:main`, and `python -m your_package` will follow the same code path via `__main__.py`.
566
+ When installed, the generated console scripts (`lib-cli-exit-tools`, `cli-exit-tools`, `lib_cli_exit_tools`) will import `your_package.cli:main`, and `python -m your_package` will follow the same code path via `__main__.py`.
566
567
 
567
568
  ## Exit Codes
568
569
 
@@ -29,14 +29,14 @@ See [INSTALL.md](INSTALL.md) for editable installs, pipx/uv usage, and troublesh
29
29
 
30
30
  ## Usage
31
31
 
32
- Console script:
32
+ Console script (all commands map to ``lib_cli_exit_tools.cli:main``):
33
33
 
34
34
  ```bash
35
35
  # After install (pip/pipx/uv tool)
36
- lib_cli_exit_tools --help
37
- cli-exit-tools --help # alias
38
- lib_cli_exit_tools info
39
- lib_cli_exit_tools fail # intentionally trigger RuntimeError to test error paths
36
+ lib-cli-exit-tools --help
37
+ lib-cli-exit-tools info
38
+ lib-cli-exit-tools fail # intentionally trigger RuntimeError to test error paths
39
+ # Aliases are also generated: `cli-exit-tools`, `lib_cli_exit_tools`
40
40
  ```
41
41
 
42
42
  ### Examples
@@ -244,9 +244,10 @@ except Exception as e:
244
244
  raise SystemExit(code)
245
245
  ```
246
246
 
247
- Command names registered on install
248
- - lib_cli_exit_tools (default)
247
+ Command names registered on install (all invoke ``lib_cli_exit_tools.cli:main``)
248
+ - lib-cli-exit-tools (default console script)
249
249
  - cli-exit-tools (alias)
250
+ - lib_cli_exit_tools (alias)
250
251
  - python -m lib_cli_exit_tools (module entry)
251
252
 
252
253
  If you installed with --user or in a venv, make sure the corresponding bin directory is on PATH:
@@ -526,7 +527,7 @@ def main(argv: Sequence[str] | None = None) -> int:
526
527
  )
527
528
  ```
528
529
 
529
- When installed, the generated console scripts (`lib_cli_exit_tools`, `cli-exit-tools`, `lib-cli-exit-tools`) will import `your_package.cli:main`, and `python -m your_package` will follow the same code path via `__main__.py`.
530
+ When installed, the generated console scripts (`lib-cli-exit-tools`, `cli-exit-tools`, `lib_cli_exit_tools`) will import `your_package.cli:main`, and `python -m your_package` will follow the same code path via `__main__.py`.
530
531
 
531
532
  ## Exit Codes
532
533
 
@@ -38,7 +38,7 @@ Downstream CLIs and automation pipelines need deterministic exit codes, signal h
38
38
  * Split runtime responsibilities into well-documented modules (`core`, `application`, `adapters`, `cli`) that mirror clean-architecture prompts.
39
39
  * Provide a lightweight metadata façade (`__init__conf__.py`) so CLI commands expose provenance without heavy packaging dependencies.
40
40
  * Funnel all exit handling through `application.runner` and `core.exit_codes`, documenting each resolver path and configuration hook.
41
- * Publish behaviour-driven tests that read like specifications and explicitly cover CLI surfaces, metadata fallbacks, module entry balance, and automation scripts.
41
+ * Publish behaviour-driven tests that read like specifications and explicitly cover CLI surfaces, metadata exports, module entry balance, and automation scripts.
42
42
  * Align system documentation with inline docstrings and testing strategy to create a single source of truth for maintainers.
43
43
 
44
44
  ---
@@ -56,7 +56,7 @@ Downstream CLIs and automation pipelines need deterministic exit codes, signal h
56
56
  5. `core.exit_codes` resolves exit codes via ordered strategies (subprocess return codes, signals, errno/winerror, sysexits, platform tables).
57
57
  6. `__init__conf__.py` supplies metadata for `info` commands and documentation output.
58
58
 
59
- **System Dependencies:** Standard library (`signal`, `sys`, `subprocess`, `importlib.metadata`, `contextlib`, `dataclasses`), `click`, `rich`, `rich-click`.
59
+ **System Dependencies:** Standard library (`signal`, `sys`, `subprocess`, `contextlib`, `dataclasses`), `click`, `rich`, `rich-click`.
60
60
 
61
61
  ---
62
62
 
@@ -78,9 +78,9 @@ Downstream CLIs and automation pipelines need deterministic exit codes, signal h
78
78
 
79
79
  ### Module: lib_cli_exit_tools/__init__conf__.py
80
80
 
81
- * **Purpose:** Provide metadata helpers and `print_info()` for CLI provenance.
82
- * **Input:** Distribution metadata via `importlib.metadata`; fallback defaults.
83
- * **Output:** Constants (`name`, `title`, `version`, `homepage`, `author`, `shell_command`) and metadata helpers (`_meta`, `_version`, `_home_page`, `_author`, `_summary`).
81
+ * **Purpose:** Publish static metadata constants and `print_info()` for CLI provenance.
82
+ * **Input:** Generated from `pyproject.toml` by `scripts._utils.sync_metadata_module`; no runtime metadata lookups.
83
+ * **Output:** Constants (`name`, `title`, `version`, `homepage`, `author`, `author_email`, `shell_command`) and the formatted `print_info()` helper.
84
84
  * **Location:** `src/lib_cli_exit_tools/__init__conf__.py`
85
85
 
86
86
  ### Module: lib_cli_exit_tools/core/configuration.py
@@ -70,7 +70,7 @@
70
70
  "outputs": [],
71
71
  "source": [
72
72
  "!lib_cli_exit_tools --version\n",
73
- "!lib_cli_exit_tools --help\n",
73
+ "!lib-cli-exit-tools --help\n",
74
74
  "!lib_cli_exit_tools info"
75
75
  ]
76
76
  },
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lib_cli_exit_tools"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "CLI exit handling helpers: clean signals, exit codes, and error printing"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -4,7 +4,7 @@ Purpose
4
4
  -------
5
5
  Collect helper functions used by the ``scripts/`` entry points (build, test,
6
6
  release) so git helpers and subprocess wrappers live in one place. The behaviour mirrors the operational guidance described in
7
- ``docs/systemdesign/concept_architecture_plan.md`` and ``DEVELOPMENT.md``.
7
+ ``docs/systemdesign/module_reference.md`` and ``DEVELOPMENT.md``.
8
8
 
9
9
  Contents
10
10
  --------
@@ -21,12 +21,15 @@ avoid duplication and keep CI/CD behaviour consistent with documentation.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import json
24
25
  import os
25
26
  import re
26
27
  import shlex
28
+ import shutil
27
29
  import subprocess
28
30
  import sys
29
31
  import tomllib
32
+ import textwrap
30
33
  from dataclasses import dataclass
31
34
  from pathlib import Path
32
35
  from subprocess import CompletedProcess
@@ -54,6 +57,12 @@ class ProjectMetadata:
54
57
  import_package: str
55
58
  coverage_source: str
56
59
  scripts: dict[str, str]
60
+ metadata_module: Path
61
+ version: str
62
+ summary: str
63
+ author_name: str
64
+ author_email: str
65
+ shell_command: str
57
66
 
58
67
  def github_tarball_url(self, version: str) -> str:
59
68
  if self.repo_host == "github.com" and self.repo_owner and self.repo_name:
@@ -90,6 +99,7 @@ class ProjectMetadata:
90
99
  summary.append(f"repository={self.repo_url}")
91
100
  if self.homepage:
92
101
  summary.append(f"homepage={self.homepage}")
102
+ summary.append(f"version={self.version}")
93
103
  return tuple(summary)
94
104
 
95
105
 
@@ -131,14 +141,7 @@ def run(
131
141
 
132
142
 
133
143
  def cmd_exists(name: str) -> bool:
134
- return (
135
- subprocess.call(
136
- ["bash", "-lc", f"command -v {shlex.quote(name)} >/dev/null 2>&1"],
137
- stdout=subprocess.DEVNULL,
138
- stderr=subprocess.DEVNULL,
139
- )
140
- == 0
141
- )
144
+ return shutil.which(name) is not None
142
145
 
143
146
 
144
147
  def _normalize_slug(value: str) -> str:
@@ -313,6 +316,47 @@ def get_project_metadata(pyproject: Path = Path("pyproject.toml")) -> ProjectMet
313
316
  coverage_source = _derive_coverage_source(data, import_package)
314
317
  scripts = _derive_scripts(data)
315
318
 
319
+ version = read_version_from_pyproject(pyproject)
320
+ summary = description.strip() if description else ""
321
+ if not summary:
322
+ summary_candidate = project_table.get("summary")
323
+ summary = summary_candidate.strip() if isinstance(summary_candidate, str) else ""
324
+ if not summary:
325
+ summary = name
326
+
327
+ author_name = ""
328
+ author_email = ""
329
+ authors_value = project_table.get("authors")
330
+ if isinstance(authors_value, list):
331
+ authors_list = cast(list[object], authors_value)
332
+ for author_entry in authors_list:
333
+ if not isinstance(author_entry, dict):
334
+ continue
335
+ author_dict = cast(dict[str, object], author_entry)
336
+ name_field = author_dict.get("name")
337
+ email_field = author_dict.get("email")
338
+ if not author_name and isinstance(name_field, str) and name_field.strip():
339
+ author_name = name_field.strip()
340
+ if not author_email and isinstance(email_field, str) and email_field.strip():
341
+ author_email = email_field.strip()
342
+ if not author_name:
343
+ author_name = repo_owner or name
344
+
345
+ shell_command = slug.replace("_", "-")
346
+ preferred_entry = _select_cli_entry(
347
+ scripts,
348
+ (
349
+ slug,
350
+ name,
351
+ import_package,
352
+ import_package.replace("_", "-"),
353
+ ),
354
+ )
355
+ if preferred_entry is not None:
356
+ shell_command = preferred_entry[0]
357
+
358
+ metadata_module = (Path("src") / import_package / "__init__conf__.py").resolve()
359
+
316
360
  meta = ProjectMetadata(
317
361
  name=name,
318
362
  description=description,
@@ -325,11 +369,109 @@ def get_project_metadata(pyproject: Path = Path("pyproject.toml")) -> ProjectMet
325
369
  import_package=import_package,
326
370
  coverage_source=coverage_source,
327
371
  scripts=scripts,
372
+ metadata_module=metadata_module,
373
+ version=version,
374
+ summary=summary,
375
+ author_name=author_name,
376
+ author_email=author_email,
377
+ shell_command=shell_command,
328
378
  )
329
379
  _METADATA_CACHE[path] = meta
330
380
  return meta
331
381
 
332
382
 
383
+ def _quote(value: str) -> str:
384
+ return json.dumps(value, ensure_ascii=False)
385
+
386
+
387
+ def _render_metadata_module(project: ProjectMetadata) -> str:
388
+ homepage = project.homepage or project.repo_url or ""
389
+ body = f'''"""Static package metadata surfaced to CLI commands and documentation.
390
+
391
+ Purpose
392
+ -------
393
+ Expose the current project metadata as simple constants. These values are kept
394
+ in sync with ``pyproject.toml`` by development automation (tests, push
395
+ pipelines), so runtime code does not query packaging metadata.
396
+
397
+ Contents
398
+ --------
399
+ * Module-level constants describing the published package.
400
+ * :func:`print_info` rendering the constants for the CLI ``info`` command.
401
+
402
+ System Role
403
+ -----------
404
+ Lives in the adapters/platform layer; CLI transports import these constants to
405
+ present authoritative project information without invoking packaging APIs.
406
+ """
407
+
408
+ from __future__ import annotations
409
+
410
+ #: Distribution name declared in ``pyproject.toml``.
411
+ name = {_quote(project.name)}
412
+ #: Human-readable summary shown in CLI help output.
413
+ title = {_quote(project.summary)}
414
+ #: Current release version pulled from ``pyproject.toml`` by automation.
415
+ version = {_quote(project.version)}
416
+ #: Repository homepage presented to users.
417
+ homepage = {_quote(homepage)}
418
+ #: Author attribution surfaced in CLI output.
419
+ author = {_quote(project.author_name)}
420
+ #: Contact email surfaced in CLI output.
421
+ author_email = {_quote(project.author_email)}
422
+ #: Console-script name published by the package.
423
+ shell_command = {_quote(project.shell_command)}
424
+
425
+
426
+ def print_info() -> None:
427
+ """Print the summarised metadata block used by the CLI ``info`` command.
428
+
429
+ Why
430
+ Provides a single, auditable rendering function so documentation and
431
+ CLI output always match the system design reference.
432
+
433
+ Side Effects
434
+ Writes to ``stdout``.
435
+
436
+ Examples
437
+ --------
438
+ >>> print_info() # doctest: +ELLIPSIS
439
+ Info for {project.name}:
440
+ ...
441
+ """
442
+
443
+ fields = [
444
+ ("name", name),
445
+ ("title", title),
446
+ ("version", version),
447
+ ("homepage", homepage),
448
+ ("author", author),
449
+ ("author_email", author_email),
450
+ ("shell_command", shell_command),
451
+ ]
452
+ pad = max(len(label) for label, _ in fields)
453
+ lines = [f"Info for {{name}}:", ""]
454
+ lines.extend(f" {{label.ljust(pad)}} = {{value}}" for label, value in fields)
455
+ print("\\n".join(lines))
456
+ '''
457
+ return textwrap.dedent(body)
458
+
459
+
460
+ def sync_metadata_module(project: ProjectMetadata) -> None:
461
+ """Write ``__init__conf__.py`` so the constants mirror ``pyproject.toml``."""
462
+
463
+ content = _render_metadata_module(project)
464
+ module_path = project.metadata_module
465
+ module_path.parent.mkdir(parents=True, exist_ok=True)
466
+ try:
467
+ existing = module_path.read_text(encoding="utf-8")
468
+ except FileNotFoundError:
469
+ existing = ""
470
+ if existing == content:
471
+ return
472
+ module_path.write_text(content, encoding="utf-8")
473
+
474
+
333
475
  def read_version_from_pyproject(pyproject: Path = Path("pyproject.toml")) -> str:
334
476
  data = _load_pyproject(pyproject)
335
477
  project_table = _as_str_mapping(data.get("project"))
@@ -4,7 +4,7 @@ import sys
4
4
 
5
5
  import rich_click as click
6
6
 
7
- from ._utils import get_project_metadata, run
7
+ from ._utils import get_project_metadata, run, sync_metadata_module
8
8
 
9
9
  __all__ = ["build_artifacts"]
10
10
 
@@ -22,6 +22,7 @@ def _failure(label: str) -> str:
22
22
  def build_artifacts() -> None:
23
23
  """Build Python wheel and sdist artifacts."""
24
24
 
25
+ sync_metadata_module(PROJECT)
25
26
  click.echo("[build] Building wheel/sdist via python -m build")
26
27
  build_result = run(["python", "-m", "build"], check=False, capture=False)
27
28
  click.echo(f"[build] {_status('success') if build_result.code == 0 else _failure('failed')}")
@@ -6,7 +6,13 @@ from pathlib import Path
6
6
 
7
7
  import rich_click as click
8
8
 
9
- from ._utils import get_project_metadata, git_branch, read_version_from_pyproject, run
9
+ from ._utils import (
10
+ get_project_metadata,
11
+ git_branch,
12
+ read_version_from_pyproject,
13
+ run,
14
+ sync_metadata_module,
15
+ )
10
16
 
11
17
  __all__ = ["push"]
12
18
 
@@ -15,6 +21,7 @@ def push(*, remote: str = "origin", message: str | None = None) -> None:
15
21
  """Run checks, commit changes, and push the current branch."""
16
22
 
17
23
  metadata = get_project_metadata()
24
+ sync_metadata_module(metadata)
18
25
  version = read_version_from_pyproject(Path("pyproject.toml")) or "unknown"
19
26
  click.echo("[push] project diagnostics: " + ", ".join(metadata.diagnostic_lines()))
20
27
  click.echo(f"[push] version={version}")
@@ -7,7 +7,7 @@ import shutil
7
7
  import subprocess
8
8
  import sys
9
9
  import tempfile
10
- from collections.abc import Callable
10
+ from collections.abc import Callable, Sequence
11
11
  from pathlib import Path
12
12
  from types import ModuleType
13
13
  from typing import cast
@@ -19,6 +19,7 @@ from ._utils import (
19
19
  bootstrap_dev,
20
20
  get_project_metadata,
21
21
  run,
22
+ sync_metadata_module,
22
23
  )
23
24
 
24
25
  PROJECT = get_project_metadata()
@@ -51,6 +52,7 @@ def _refresh_default_env() -> None:
51
52
  def run_coverage(*, verbose: bool = False) -> None:
52
53
  """Run pytest under coverage using python modules to avoid PATH shim issues."""
53
54
 
55
+ sync_metadata_module(PROJECT)
54
56
  bootstrap_dev()
55
57
  _prune_coverage_data_files()
56
58
  _remove_report_artifacts()
@@ -95,23 +97,16 @@ def _resolve_pip_audit_ignores() -> list[str]:
95
97
  def _extract_audit_dependencies(payload: object) -> _AuditPayload:
96
98
  """Normalise `pip-audit --format json` output into dictionaries."""
97
99
 
98
- dependencies: _AuditPayload = []
99
- candidate_iter: list[object] = []
100
- if isinstance(payload, dict):
101
- payload_dict = cast(dict[str, object], payload)
102
- raw_candidates = payload_dict.get("dependencies", [])
103
- if isinstance(raw_candidates, list):
104
- candidate_iter = list(cast(list[object], raw_candidates))
105
- elif isinstance(payload, list): # pragma: no cover - legacy output
106
- candidate_iter = list(cast(list[object], payload))
107
- else: # pragma: no cover - defensive
108
- return dependencies
100
+ if not isinstance(payload, dict):
101
+ return []
109
102
 
110
- for candidate in candidate_iter:
111
- if isinstance(candidate, dict):
112
- dependencies.append(cast(dict[str, object], candidate))
103
+ payload_dict = cast(dict[str, object], payload)
104
+ raw_candidates = payload_dict.get("dependencies", [])
105
+ if not isinstance(raw_candidates, list):
106
+ return []
113
107
 
114
- return dependencies
108
+ candidate_list = cast(list[object], raw_candidates)
109
+ return [cast(dict[str, object], candidate) for candidate in candidate_list if isinstance(candidate, dict)]
115
110
 
116
111
 
117
112
  def run_tests(*, coverage: str = "on", verbose: bool = False, strict_format: bool | None = None) -> None:
@@ -119,8 +114,10 @@ def run_tests(*, coverage: str = "on", verbose: bool = False, strict_format: boo
119
114
  if not verbose and env_verbose in _TRUTHY:
120
115
  verbose = True
121
116
 
117
+ sync_metadata_module(PROJECT)
118
+
122
119
  def _run(
123
- cmd: list[str] | str,
120
+ cmd: Sequence[str] | str,
124
121
  *,
125
122
  env: dict[str, str] | None = None,
126
123
  check: bool = True,
@@ -138,7 +135,7 @@ def run_tests(*, coverage: str = "on", verbose: bool = False, strict_format: boo
138
135
  env_view = " ".join(f"{k}={v}" for k, v in overrides.items())
139
136
  click.echo(f" env {env_view}")
140
137
  merged_env = _default_env if env is None else _default_env | env
141
- result = run(cmd, env=merged_env, check=False, capture=capture) # type: ignore[arg-type]
138
+ result = run(cmd, env=merged_env, check=False, capture=capture)
142
139
  if verbose and label:
143
140
  click.echo(f" -> {label}: exit={result.code} out={bool(result.out)} err={bool(result.err)}")
144
141
 
@@ -292,11 +289,8 @@ def run_tests(*, coverage: str = "on", verbose: bool = False, strict_format: boo
292
289
  )
293
290
 
294
291
  def _run_pytest() -> None:
295
- for f in (".coverage", "coverage.xml"):
296
- try:
297
- Path(f).unlink()
298
- except FileNotFoundError:
299
- pass
292
+ for path in (Path(".coverage"), Path("coverage.xml")):
293
+ path.unlink(missing_ok=True)
300
294
 
301
295
  if coverage == "on" or (coverage == "auto" and (os.getenv("CI") or os.getenv("CODECOV_TOKEN"))):
302
296
  click.echo("[coverage] enabled")
@@ -0,0 +1,67 @@
1
+ """Static package metadata surfaced to CLI commands and documentation.
2
+
3
+ Purpose
4
+ -------
5
+ Expose the current project metadata as simple constants. These values are kept
6
+ in sync with ``pyproject.toml`` by development automation (tests, push
7
+ pipelines), so runtime code does not query packaging metadata.
8
+
9
+ Contents
10
+ --------
11
+ * Module-level constants describing the published package.
12
+ * :func:`print_info` rendering the constants for the CLI ``info`` command.
13
+
14
+ System Role
15
+ -----------
16
+ Lives in the adapters/platform layer; CLI transports import these constants to
17
+ present authoritative project information without invoking packaging APIs.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ #: Distribution name declared in ``pyproject.toml``.
23
+ name = "lib_cli_exit_tools"
24
+ #: Human-readable summary shown in CLI help output.
25
+ title = "CLI exit handling helpers: clean signals, exit codes, and error printing"
26
+ #: Current release version pulled from ``pyproject.toml`` by automation.
27
+ version = "2.1.0"
28
+ #: Repository homepage presented to users.
29
+ homepage = "https://github.com/bitranox/lib_cli_exit_tools"
30
+ #: Author attribution surfaced in CLI output.
31
+ author = "bitranox"
32
+ #: Contact email surfaced in CLI output.
33
+ author_email = "bitranox@gmail.com"
34
+ #: Console-script name published by the package.
35
+ shell_command = "lib-cli-exit-tools"
36
+
37
+
38
+ def print_info() -> None:
39
+ """Print the summarised metadata block used by the CLI ``info`` command.
40
+
41
+ Why
42
+ Provides a single, auditable rendering function so documentation and
43
+ CLI output always match the system design reference.
44
+
45
+ Side Effects
46
+ Writes to ``stdout``.
47
+
48
+ Examples
49
+ --------
50
+ >>> print_info() # doctest: +ELLIPSIS
51
+ Info for lib_cli_exit_tools:
52
+ ...
53
+ """
54
+
55
+ fields = [
56
+ ("name", name),
57
+ ("title", title),
58
+ ("version", version),
59
+ ("homepage", homepage),
60
+ ("author", author),
61
+ ("author_email", author_email),
62
+ ("shell_command", shell_command),
63
+ ]
64
+ pad = max(len(label) for label, _ in fields)
65
+ lines = [f"Info for {name}:", ""]
66
+ lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
67
+ print("\n".join(lines))
@@ -136,7 +136,7 @@ def _store_traceback_flag(ctx: click.Context, traceback: bool) -> None:
136
136
 
137
137
  @cli.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
138
138
  def cli_info() -> None:
139
- """Display package metadata sourced from :mod:`importlib.metadata`.
139
+ """Display package metadata exported by :mod:`lib_cli_exit_tools.__init__conf__`.
140
140
 
141
141
  Why:
142
142
  Offer a zero-dependency way for users to confirm the installed version
@@ -75,6 +75,18 @@ def test_when_info_is_invoked_metadata_is_displayed(cli_runner: CliRunner, strip
75
75
  assert field in plain_output
76
76
 
77
77
 
78
+ @pytest.mark.os_agnostic
79
+ def test_when_version_flag_is_used_metadata_banner_is_returned(
80
+ cli_runner: CliRunner,
81
+ strip_ansi: Callable[[str], str],
82
+ ) -> None:
83
+ result: Result = cli_runner.invoke(cli_mod.cli, ["--version"])
84
+ plain_output = strip_ansi(result.output).strip()
85
+
86
+ assert result.exit_code == 0
87
+ assert plain_output == f"{metadata.shell_command} version {metadata.version}"
88
+
89
+
78
90
  @pytest.mark.os_agnostic
79
91
  def test_when_fail_is_invoked_a_runtime_error_surfaces(cli_runner: CliRunner) -> None:
80
92
  result: Result = cli_runner.invoke(cli_mod.cli, ["fail"])
@@ -0,0 +1,47 @@
1
+ """Metadata stories ensuring generated constants stay authoritative."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from lib_cli_exit_tools import __init__conf__ as metadata
8
+ from scripts import _utils
9
+
10
+
11
+ @pytest.mark.os_agnostic
12
+ def test_when_print_info_runs_it_lists_every_field(capsys: pytest.CaptureFixture[str]) -> None:
13
+ metadata.print_info()
14
+
15
+ captured = capsys.readouterr().out.splitlines()
16
+ banner, *detail_lines = [line.strip() for line in captured if line.strip()]
17
+ assert banner == f"Info for {metadata.name}:"
18
+
19
+ rows = {}
20
+ for line in detail_lines:
21
+ if " = " not in line:
22
+ continue
23
+ label, value = line.split(" = ", 1)
24
+ rows[label.strip()] = value.strip()
25
+
26
+ assert rows == {
27
+ "name": metadata.name,
28
+ "title": metadata.title,
29
+ "version": metadata.version,
30
+ "homepage": metadata.homepage,
31
+ "author": metadata.author,
32
+ "author_email": metadata.author_email,
33
+ "shell_command": metadata.shell_command,
34
+ }
35
+
36
+
37
+ @pytest.mark.os_agnostic
38
+ def test_metadata_constants_match_pyproject() -> None:
39
+ project = _utils.get_project_metadata()
40
+
41
+ assert metadata.name == project.name
42
+ assert metadata.title == project.summary
43
+ assert metadata.version == project.version
44
+ assert metadata.homepage == project.homepage or project.repo_url or ""
45
+ assert metadata.author == project.author_name
46
+ assert metadata.author_email == project.author_email
47
+ assert metadata.shell_command == project.shell_command
@@ -1,220 +0,0 @@
1
- """Metadata facade that keeps CLI help/version text aligned with packaging data.
2
-
3
- Purpose:
4
- Expose read-only metadata helpers the CLI can use without importing heavy
5
- packaging libraries.
6
- Contents:
7
- * Metadata lookup helpers prefixed with ``_`` to cache and normalise states.
8
- * Public constants (`name`, `title`, `version`, etc.) derived from metadata.
9
- * :func:`print_info` for human-friendly provenance output.
10
- System Integration:
11
- Read by :mod:`lib_cli_exit_tools.cli` and documented in
12
- ``docs/system-design/module_reference.md`` so CLI output matches project
13
- release metadata.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- from functools import cache
19
- from importlib import metadata as _im
20
- from importlib.metadata import PackageMetadata
21
-
22
- #: Distribution identifier used for importlib.metadata lookups. Mirrors the
23
- #: project name declared in ``pyproject.toml`` so metadata resolution stays in
24
- #: sync with packaging configuration.
25
- _DIST_NAME = "lib_cli_exit_tools"
26
-
27
-
28
- def _get_str(meta: PackageMetadata | None, key: str, default: str = "") -> str:
29
- """Why:
30
- Normalise metadata lookups so CLI output stays predictable.
31
- What:
32
- Fetch the string value for ``key`` from ``meta`` or fall back to
33
- ``default`` when the entry is missing or not a plain string.
34
- Parameters:
35
- meta: Metadata mapping for the installed distribution, or ``None``
36
- when running from source.
37
- key: Field name to fetch (e.g. ``"Summary"``).
38
- default: Fallback string when the key is absent or unsuitable.
39
- Returns:
40
- The resolved string value to display in CLI output.
41
- Side Effects:
42
- None.
43
- """
44
- if meta is None:
45
- return default
46
- value = meta.get(key)
47
- return value if isinstance(value, str) else default
48
-
49
-
50
- def _meta() -> PackageMetadata | None:
51
- """Why:
52
- Provide a single location that tolerates missing installations.
53
- What:
54
- Attempt to read the distribution metadata via ``importlib.metadata``.
55
- Parameters:
56
- None.
57
- Returns:
58
- Metadata mapping when available, otherwise ``None``.
59
- Side Effects:
60
- None.
61
- """
62
- try:
63
- meta: PackageMetadata = _im.metadata(_DIST_NAME)
64
- return meta
65
- except _im.PackageNotFoundError:
66
- return None
67
-
68
-
69
- def _version() -> str:
70
- """Why:
71
- Surface the installed version for CLI flags and documentation.
72
- What:
73
- Retrieve the distribution version, falling back to a development label
74
- when metadata is unavailable.
75
- Parameters:
76
- None.
77
- Returns:
78
- Semantic-version string.
79
- Side Effects:
80
- None.
81
- Examples:
82
- >>> isinstance(_version(), str)
83
- True
84
- """
85
- try:
86
- return _im.version(_DIST_NAME)
87
- except _im.PackageNotFoundError:
88
- return "0.0.0.dev0"
89
-
90
-
91
- def _home_page(meta: PackageMetadata | None) -> str:
92
- """Why:
93
- Normalise homepage lookups across build backends.
94
- What:
95
- Return the documented project homepage, favouring metadata values but
96
- defaulting to GitHub when absent.
97
- Parameters:
98
- meta: Metadata mapping or ``None`` when unavailable.
99
- Returns:
100
- Homepage URL string.
101
- Side Effects:
102
- None.
103
- """
104
- if meta is None:
105
- return "https://github.com/bitranox/lib_cli_exit_tools"
106
- primary = _get_str(meta, "Home-page")
107
- fallback = _get_str(meta, "Homepage")
108
- return primary or fallback or "https://github.com/bitranox/lib_cli_exit_tools"
109
-
110
-
111
- def _author(meta: PackageMetadata | None) -> tuple[str, str]:
112
- """Why:
113
- Ensure provenance output always includes a name and contact address.
114
- What:
115
- Extract the author name and email from metadata, applying defaults.
116
- Parameters:
117
- meta: Metadata mapping or ``None`` when unavailable.
118
- Returns:
119
- Tuple of ``(author_name, author_email)``.
120
- Side Effects:
121
- None.
122
- """
123
- if meta is None:
124
- return ("bitranox", "bitranox@gmail.com")
125
- return (
126
- _get_str(meta, "Author", "bitranox"),
127
- _get_str(meta, "Author-email", "bitranox@gmail.com"),
128
- )
129
-
130
-
131
- def _summary(meta: PackageMetadata | None) -> str:
132
- """Why:
133
- Guarantee a descriptive summary in CLI output even when metadata is sparse.
134
- What:
135
- Return the ``Summary`` field or a human-readable default.
136
- Parameters:
137
- meta: Metadata mapping or ``None`` when missing.
138
- Returns:
139
- Summary string.
140
- Side Effects:
141
- None.
142
- """
143
- return _get_str(meta, "Summary", "Functions to exit a CLI application properly")
144
-
145
-
146
- @cache
147
- def _shell_command() -> str:
148
- """Why:
149
- Surface the console-script name so documentation matches installation.
150
- What:
151
- Inspect entry points for the target referencing ``cli:main`` and return
152
- its advertised name.
153
- Parameters:
154
- None.
155
- Returns:
156
- Console-script name or the distribution identifier when unresolved.
157
- Side Effects:
158
- None.
159
- Examples:
160
- >>> isinstance(_shell_command(), str)
161
- True
162
- """
163
- # Discover console script name mapping to our CLI main, fallback to dist name
164
- entries = _im.entry_points(group="console_scripts")
165
- target = "lib_cli_exit_tools.cli:main"
166
- for entry in entries:
167
- if entry.value == target:
168
- return entry.name
169
- return _DIST_NAME
170
-
171
-
172
- # Public values (resolve metadata once)
173
- #: Cached metadata mapping to avoid repeated importlib lookups.
174
- _m = _meta()
175
- #: Distribution name used when metadata lookups fail.
176
- name = _DIST_NAME
177
- #: Human-readable project title displayed in CLI help.
178
- title = _summary(_m)
179
- #: Installed package version string surfaced via --version.
180
- version = _version()
181
- #: Project homepage for documentation and issue reporting.
182
- homepage = _home_page(_m)
183
- #: Primary author details used in info output.
184
- author, author_email = _author(_m)
185
- #: Console script entry point that launches this CLI.
186
- shell_command = _shell_command()
187
-
188
-
189
- def print_info() -> None:
190
- """Why:
191
- Offer operators a quick snapshot of build provenance and support links.
192
- What:
193
- Emit a formatted block containing metadata derived from this module.
194
- Parameters:
195
- None.
196
- Returns:
197
- ``None``.
198
- Side Effects:
199
- Writes text to ``stdout``.
200
- Examples:
201
- >>> import contextlib, io
202
- >>> buf = io.StringIO()
203
- >>> with contextlib.redirect_stdout(buf):
204
- ... print_info()
205
- >>> "Info for" in buf.getvalue()
206
- True
207
- """
208
- fields = [
209
- ("name", name),
210
- ("title", title),
211
- ("version", version),
212
- ("homepage", homepage),
213
- ("author", author),
214
- ("author_email", author_email),
215
- ("shell_command", shell_command),
216
- ]
217
- pad = max(len(k) for k, _ in fields)
218
- lines = [f"Info for {name}:", ""]
219
- lines += [f" {k.ljust(pad)} = {v}" for k, v in fields]
220
- print("\n".join(lines))
@@ -1,101 +0,0 @@
1
- """Metadata stories ensuring importlib fallbacks stay predictable."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib
6
-
7
- import pytest
8
-
9
- from lib_cli_exit_tools import __init__conf__ as metadata
10
-
11
-
12
- @pytest.mark.os_agnostic
13
- def test_when_print_info_runs_it_lists_every_field(capsys: pytest.CaptureFixture[str]) -> None:
14
- metadata.print_info()
15
-
16
- captured = capsys.readouterr().out
17
-
18
- for label in ("name", "title", "version", "homepage", "author", "author_email", "shell_command"):
19
- assert label in captured
20
-
21
-
22
- @pytest.mark.os_agnostic
23
- def test_when_distribution_is_missing_fallback_values_surface(
24
- monkeypatch: pytest.MonkeyPatch,
25
- capsys: pytest.CaptureFixture[str],
26
- ) -> None:
27
- class FailingLookup:
28
- def __call__(self, _dist: str) -> None:
29
- raise metadata._im.PackageNotFoundError # type: ignore[attr-defined]
30
-
31
- with monkeypatch.context() as ctx:
32
- ctx.setattr(metadata._im, "metadata", FailingLookup()) # type: ignore[attr-defined]
33
- ctx.setattr(metadata._im, "version", FailingLookup()) # type: ignore[attr-defined]
34
- reloaded = importlib.reload(metadata)
35
-
36
- reloaded.print_info()
37
-
38
- captured = capsys.readouterr().out
39
-
40
- assert "0.0.0.dev0" in captured
41
- assert "lib_cli_exit_tools" in captured
42
- assert "bitranox@gmail.com" in captured
43
-
44
- importlib.reload(metadata)
45
-
46
-
47
- @pytest.mark.os_agnostic
48
- def test_when_shell_command_is_resolved_matching_entry_point_wins(monkeypatch: pytest.MonkeyPatch) -> None:
49
- class EntryPoint:
50
- def __init__(self, name: str, value: str) -> None:
51
- self.name = name
52
- self.value = value
53
-
54
- def fake_entry_points(*, group: str) -> list[EntryPoint]:
55
- assert group == "console_scripts"
56
- return [
57
- EntryPoint("alt-cli", "other.module:main"),
58
- EntryPoint("lib-cli-exit-tools", "lib_cli_exit_tools.cli:main"),
59
- ]
60
-
61
- with monkeypatch.context() as ctx:
62
- ctx.setattr(metadata._im, "entry_points", fake_entry_points) # type: ignore[attr-defined]
63
- reloaded = importlib.reload(metadata)
64
-
65
- assert reloaded.shell_command == "lib-cli-exit-tools"
66
-
67
- importlib.reload(metadata)
68
-
69
-
70
- @pytest.mark.os_agnostic
71
- def test_when_shell_command_has_no_match_the_distribution_name_is_used(monkeypatch: pytest.MonkeyPatch) -> None:
72
- def fake_entry_points(*, group: str) -> list[object]:
73
- assert group == "console_scripts"
74
- return []
75
-
76
- with monkeypatch.context() as ctx:
77
- ctx.setattr(metadata._im, "entry_points", fake_entry_points) # type: ignore[attr-defined]
78
- reloaded = importlib.reload(metadata)
79
- assert reloaded.shell_command == "lib_cli_exit_tools"
80
-
81
- importlib.reload(metadata)
82
-
83
-
84
- @pytest.mark.os_agnostic
85
- def test_when_metadata_values_are_not_strings_defaults_apply(monkeypatch: pytest.MonkeyPatch) -> None:
86
- class OddMeta:
87
- def get(self, key: str, default: object | None = None) -> object:
88
- return 123
89
-
90
- def fake_metadata(_dist: str) -> OddMeta:
91
- return OddMeta()
92
-
93
- with monkeypatch.context() as ctx:
94
- ctx.setattr(metadata._im, "metadata", fake_metadata) # type: ignore[attr-defined]
95
- reloaded = importlib.reload(metadata)
96
-
97
- assert reloaded.author == "bitranox"
98
- assert reloaded.author_email == "bitranox@gmail.com"
99
- assert reloaded.title == "Functions to exit a CLI application properly"
100
-
101
- importlib.reload(metadata)