code-standards 7.0.0__py3-none-any.whl

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 (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
@@ -0,0 +1,98 @@
1
+ from sarj_standards.libs.release.artifacts import (
2
+ required_artifact_paths,
3
+ verify_built_package,
4
+ verify_package_tarball,
5
+ verify_python_wheel_license,
6
+ )
7
+ from sarj_standards.libs.release.causality import (
8
+ CausalityViolation,
9
+ ReleaseCausalityReport,
10
+ check_release_causality,
11
+ )
12
+ from sarj_standards.libs.release.changes import changed_release_targets, pending_release_targets
13
+ from sarj_standards.libs.release.process import (
14
+ ProcessFailureError,
15
+ ProcessResult,
16
+ ProcessRunner,
17
+ credential_free_environment,
18
+ run_build_process,
19
+ run_process,
20
+ run_process_environment,
21
+ )
22
+ from sarj_standards.libs.release.publish import PublishTarget, publish_target
23
+ from sarj_standards.libs.release.release_age import (
24
+ PackageIdentity,
25
+ PackumentFetcher,
26
+ ReleaseAgeFailure,
27
+ ReleaseAgePolicy,
28
+ ReleaseAgeReport,
29
+ check_lockfile_release_age,
30
+ fetch_npm_packument,
31
+ load_exact_exclusions,
32
+ locked_registry_packages,
33
+ )
34
+ from sarj_standards.libs.release.tags import (
35
+ RELEASE_TARGETS,
36
+ ReleaseTarget,
37
+ ReleaseTargetId,
38
+ TagSyncResult,
39
+ ValidatedReleaseTag,
40
+ create_release_tags,
41
+ missing_remote_release_tags,
42
+ read_manifest_version,
43
+ validate_release_tag,
44
+ verify_remote_release_tags,
45
+ )
46
+ from sarj_standards.libs.release.typescript import (
47
+ PackedArtifact,
48
+ ReleaseMode,
49
+ check_typescript,
50
+ pack_typescript,
51
+ run_typescript_release,
52
+ )
53
+
54
+
55
+ __all__ = (
56
+ "RELEASE_TARGETS",
57
+ "CausalityViolation",
58
+ "PackageIdentity",
59
+ "PackedArtifact",
60
+ "PackumentFetcher",
61
+ "ProcessFailureError",
62
+ "ProcessResult",
63
+ "ProcessRunner",
64
+ "PublishTarget",
65
+ "ReleaseAgeFailure",
66
+ "ReleaseAgePolicy",
67
+ "ReleaseAgeReport",
68
+ "ReleaseCausalityReport",
69
+ "ReleaseMode",
70
+ "ReleaseTarget",
71
+ "ReleaseTargetId",
72
+ "TagSyncResult",
73
+ "ValidatedReleaseTag",
74
+ "changed_release_targets",
75
+ "check_lockfile_release_age",
76
+ "check_release_causality",
77
+ "check_typescript",
78
+ "create_release_tags",
79
+ "credential_free_environment",
80
+ "fetch_npm_packument",
81
+ "load_exact_exclusions",
82
+ "locked_registry_packages",
83
+ "missing_remote_release_tags",
84
+ "pack_typescript",
85
+ "pending_release_targets",
86
+ "publish_target",
87
+ "read_manifest_version",
88
+ "required_artifact_paths",
89
+ "run_build_process",
90
+ "run_process",
91
+ "run_process_environment",
92
+ "run_typescript_release",
93
+ "validate_release_tag",
94
+ "verify_built_package",
95
+ "verify_package_tarball",
96
+ "verify_python_wheel_license",
97
+ "verify_remote_release_tags",
98
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeIs
4
+
5
+
6
+ def is_object_list(value: object) -> TypeIs[list[object]]:
7
+ return isinstance(value, list)
8
+
9
+
10
+ def is_object_dict(value: object) -> TypeIs[dict[object, object]]:
11
+ return isinstance(value, dict)
12
+
13
+
14
+ def string_object_dict(value: object, *, label: str) -> dict[str, object]:
15
+ if not is_object_dict(value):
16
+ msg = f"{label} must contain an object or table"
17
+ raise TypeError(msg)
18
+ result: dict[str, object] = {}
19
+ for key, item in value.items():
20
+ if not isinstance(key, str):
21
+ msg = f"{label} contains a non-string key"
22
+ raise TypeError(msg)
23
+ result[key] = item
24
+ return result
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path, PurePosixPath
5
+ import tarfile
6
+ from typing import TYPE_CHECKING, NamedTuple
7
+ import zipfile
8
+
9
+ from sarj_standards.libs.release._values import is_object_dict, is_object_list, string_object_dict
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping, Sequence
14
+
15
+
16
+ type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
17
+ _INSTALL_LIFECYCLE_SCRIPTS = frozenset({"preinstall", "install", "postinstall", "prepare"})
18
+
19
+
20
+ class _InspectedMembers(NamedTuple):
21
+ found: set[str]
22
+ identity_verified: bool
23
+
24
+
25
+ def _json_value(value: object) -> JsonValue:
26
+ match value:
27
+ case None | str() | int() | float() | bool():
28
+ return value
29
+ case _ if is_object_list(value):
30
+ return [_json_value(item) for item in value]
31
+ case _ if is_object_dict(value):
32
+ return {key: _json_value(item) for key, item in string_object_dict(value, label="JSON").items()}
33
+ case _:
34
+ msg = "JSON contains an unsupported value"
35
+ raise TypeError(msg)
36
+
37
+
38
+ def required_artifact_paths(package_json: Mapping[str, object]) -> tuple[str, ...]:
39
+ candidates: list[str] = []
40
+ for field in ("main", "module", "types"):
41
+ candidates.extend(_exported_paths(_json_value(package_json.get(field))))
42
+ candidates.extend(_exported_paths(_json_value(package_json.get("exports"))))
43
+ return tuple(dict.fromkeys(_safe_artifact_path(path) for path in candidates))
44
+
45
+
46
+ def _exported_paths(value: JsonValue) -> tuple[str, ...]:
47
+ match value:
48
+ case str():
49
+ return (value,)
50
+ case list():
51
+ return tuple(path for item in value for path in _exported_paths(item))
52
+ case dict():
53
+ return tuple(path for item in value.values() for path in _exported_paths(item))
54
+ case _:
55
+ return ()
56
+
57
+
58
+ def _safe_artifact_path(value: str) -> str:
59
+ normalized = value.removeprefix("./")
60
+ path = PurePosixPath(normalized)
61
+ if not normalized or path.is_absolute() or ".." in path.parts or path.as_posix() != normalized:
62
+ msg = f"unsafe exported entry point: {value}"
63
+ raise ValueError(msg)
64
+ return normalized
65
+
66
+
67
+ def load_package_json(package_root: Path) -> dict[str, JsonValue]:
68
+ manifest = package_root / "package.json"
69
+ try:
70
+ untyped: object = json.loads(manifest.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
71
+ value = _json_value(untyped)
72
+ except (OSError, json.JSONDecodeError) as exc:
73
+ msg = f"could not read {manifest}: {exc}"
74
+ raise ValueError(msg) from exc
75
+ if not isinstance(value, dict):
76
+ msg = f"{manifest} must contain a JSON object"
77
+ raise TypeError(msg)
78
+ return value
79
+
80
+
81
+ def verify_built_package(package_root: Path) -> tuple[str, ...]:
82
+ required = required_artifact_paths(load_package_json(package_root))
83
+ if not required:
84
+ msg = "package.json declares no publishable entry points"
85
+ raise ValueError(msg)
86
+ resolved_root = package_root.resolve()
87
+ for relative in required:
88
+ if PurePosixPath(relative).parts[0] != "dist":
89
+ msg = f"exported entry point must live under dist/: {relative}"
90
+ raise ValueError(msg)
91
+ artifact = package_root / relative
92
+ try:
93
+ artifact.resolve().relative_to(resolved_root)
94
+ except (OSError, ValueError) as exc:
95
+ msg = f"exported entry point escapes the package root: {relative}"
96
+ raise ValueError(msg) from exc
97
+ if artifact.is_symlink() or not artifact.is_file() or artifact.stat().st_size == 0:
98
+ msg = f"exported entry point is missing or empty: {relative}"
99
+ raise ValueError(msg)
100
+ return required
101
+
102
+
103
+ def verify_package_tarball(
104
+ tarball: Path,
105
+ required: Sequence[str],
106
+ *,
107
+ expected_name: str | None = None,
108
+ expected_version: str | None = None,
109
+ ) -> tuple[str, ...]:
110
+ expected = {"package/LICENSE", *(f"package/{path}" for path in required)}
111
+ try:
112
+ with tarfile.open(tarball, mode="r:gz") as archive:
113
+ found, identity_verified = _inspect_members(
114
+ archive,
115
+ expected,
116
+ expected_name=expected_name,
117
+ expected_version=expected_version,
118
+ )
119
+ except (OSError, tarfile.TarError) as exc:
120
+ msg = f"could not inspect package archive {tarball}: {exc}"
121
+ raise ValueError(msg) from exc
122
+ missing = sorted(expected - found)
123
+ if missing:
124
+ msg = f"packed artifact omits exported entry point: {missing[0].removeprefix('package/')}"
125
+ raise ValueError(msg)
126
+ if (expected_name is not None or expected_version is not None) and not identity_verified:
127
+ msg = "package archive omits package/package.json identity"
128
+ raise ValueError(msg)
129
+ return tuple(required)
130
+
131
+
132
+ def verify_python_wheel_license(wheel: Path) -> None:
133
+ try:
134
+ with zipfile.ZipFile(wheel) as archive:
135
+ licenses = [name for name in archive.namelist() if PurePosixPath(name).name == "LICENSE"]
136
+ if not licenses or any(not archive.read(name) for name in licenses):
137
+ msg = f"wheel omits a non-empty LICENSE: {wheel.name}"
138
+ raise ValueError(msg)
139
+ except zipfile.BadZipFile as exc:
140
+ msg = f"could not inspect wheel {wheel.name}"
141
+ raise ValueError(msg) from exc
142
+
143
+
144
+ def _inspect_members(
145
+ archive: tarfile.TarFile,
146
+ expected: set[str],
147
+ *,
148
+ expected_name: str | None,
149
+ expected_version: str | None,
150
+ ) -> _InspectedMembers:
151
+ found: set[str] = set()
152
+ identity_verified = False
153
+ for member in archive.getmembers():
154
+ path = PurePosixPath(member.name)
155
+ if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] != "package":
156
+ msg = f"unsafe path in package archive: {member.name}"
157
+ raise ValueError(msg)
158
+ if member.issym() or member.islnk():
159
+ msg = f"links are forbidden in package archive: {member.name}"
160
+ raise ValueError(msg)
161
+ if member.isfile() and member.name.endswith(".map"):
162
+ msg = f"source maps are forbidden in package archive: {member.name}"
163
+ raise ValueError(msg)
164
+ if member.name == "package/package.json" and member.isfile():
165
+ manifest_file = archive.extractfile(member)
166
+ if manifest_file is None:
167
+ msg = "could not read package/package.json from package archive"
168
+ raise ValueError(msg)
169
+ try:
170
+ manifest: object = json.load(manifest_file) # pyright: ignore[reportAny]
171
+ except json.JSONDecodeError as exc:
172
+ msg = "package archive contains invalid package/package.json"
173
+ raise ValueError(msg) from exc
174
+ manifest_data = string_object_dict(manifest, label="packed package.json")
175
+ for field, expected_value in (("name", expected_name), ("version", expected_version)):
176
+ if expected_value is not None and manifest_data.get(field) != expected_value:
177
+ msg = f"packed package {field} does not match source manifest"
178
+ raise ValueError(msg)
179
+ identity_verified = True
180
+ scripts = manifest_data.get("scripts")
181
+ if is_object_dict(scripts):
182
+ dangerous = _INSTALL_LIFECYCLE_SCRIPTS.intersection(scripts)
183
+ if dangerous:
184
+ msg = f"install lifecycle script is forbidden in package archive: {min(dangerous)}"
185
+ raise ValueError(msg)
186
+ if member.name in expected:
187
+ if not member.isfile() or member.size == 0:
188
+ msg = f"packed entry point is missing, empty, or not a regular file: {member.name}"
189
+ raise ValueError(msg)
190
+ found.add(member.name)
191
+ return _InspectedMembers(found, identity_verified)
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .changes import changed_release_targets
7
+ from .process import ProcessRunner, run_process
8
+ from .tags import RELEASE_ARTIFACT_FILES, RELEASE_ARTIFACT_PREFIXES, RELEASE_TARGETS
9
+
10
+
11
+ _DISPLAY_PATH_LIMIT = 3
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CausalityViolation:
16
+ target: str
17
+ manifest: Path
18
+ version_field: str
19
+ changed_paths: tuple[str, ...]
20
+
21
+ def render(self) -> str:
22
+ paths = ", ".join(self.changed_paths[:_DISPLAY_PATH_LIMIT])
23
+ suffix = (
24
+ f" (+{len(self.changed_paths) - _DISPLAY_PATH_LIMIT} more)"
25
+ if len(self.changed_paths) > _DISPLAY_PATH_LIMIT
26
+ else ""
27
+ )
28
+ return (
29
+ f"{self.target}: bump {self.version_field} in {self.manifest}; publishable files changed: {paths}{suffix}"
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ReleaseCausalityReport:
35
+ before: str
36
+ after: str
37
+ changed_targets: tuple[str, ...]
38
+ bumped_targets: tuple[str, ...]
39
+ violations: tuple[CausalityViolation, ...]
40
+
41
+ @property
42
+ def ok(self) -> bool:
43
+ return not self.violations
44
+
45
+
46
+ def check_release_causality(
47
+ root: Path,
48
+ *,
49
+ before: str,
50
+ after: str,
51
+ runner: ProcessRunner = run_process,
52
+ ) -> ReleaseCausalityReport:
53
+ result = runner(("git", "diff", "--name-only", "-z", before, after, "--"), cwd=root, capture_output=True)
54
+ changed_paths = tuple(sorted(path for path in result.stdout.split("\0") if path))
55
+ bumped = changed_release_targets(root, before=before, after=after, runner=runner)
56
+ by_target = {
57
+ name: tuple(path for path in changed_paths if _belongs_to_artifact(name, path=path)) for name in RELEASE_TARGETS
58
+ }
59
+ changed_targets = tuple(name for name, paths in by_target.items() if paths)
60
+ violations = tuple(
61
+ CausalityViolation(
62
+ name,
63
+ RELEASE_TARGETS[name].manifest,
64
+ 'top-level "version"' if RELEASE_TARGETS[name].format == "json" else "[project].version",
65
+ by_target[name],
66
+ )
67
+ for name in changed_targets
68
+ if not bumped[name]
69
+ )
70
+ return ReleaseCausalityReport(
71
+ before,
72
+ after,
73
+ changed_targets,
74
+ tuple(name for name, changed in bumped.items() if changed),
75
+ violations,
76
+ )
77
+
78
+
79
+ def _belongs_to_artifact(target: str, *, path: str) -> bool:
80
+ return path in RELEASE_ARTIFACT_FILES[target] or path.startswith(RELEASE_ARTIFACT_PREFIXES[target])
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING
5
+
6
+ from sarj_standards.libs.release.process import ProcessRunner, run_process
7
+ from sarj_standards.libs.release.registry import PublicationChecker, publication_exists, target_requirement
8
+ from sarj_standards.libs.release.tags import RELEASE_TARGETS
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+
15
+
16
+ _ADDED_JSON_VERSION = re.compile(r'(?m)^\+\s*"version"\s*:')
17
+ _ADDED_TOML_VERSION = re.compile(r"(?m)^\+version\s*=")
18
+
19
+
20
+ def changed_release_targets(
21
+ root: Path,
22
+ *,
23
+ before: str,
24
+ after: str,
25
+ runner: ProcessRunner = run_process,
26
+ ) -> Mapping[str, bool]:
27
+ changed: dict[str, bool] = {}
28
+ for name, target in RELEASE_TARGETS.items():
29
+ result = runner(
30
+ ("git", "diff", "--no-color", before, after, "--", target.manifest.as_posix()),
31
+ cwd=root,
32
+ capture_output=True,
33
+ )
34
+ pattern = _ADDED_JSON_VERSION if target.format == "json" else _ADDED_TOML_VERSION
35
+ changed[name] = pattern.search(result.stdout) is not None
36
+ return changed
37
+
38
+
39
+ def pending_release_targets(
40
+ root: Path,
41
+ *,
42
+ before: str,
43
+ after: str,
44
+ runner: ProcessRunner = run_process,
45
+ checker: PublicationChecker = publication_exists,
46
+ ) -> Mapping[str, bool]:
47
+ _ = changed_release_targets(root, before=before, after=after, runner=runner)
48
+ return {name: not checker(target_requirement(root, name)) for name in RELEASE_TARGETS}
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ import subprocess # ruff: ignore[suspicious-subprocess-import] -- Centralized argv-only process adapter; shell execution is never enabled.
6
+ import tempfile
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+
14
+
15
+ _BLOCKED_CREDENTIAL_NAMES = frozenset(
16
+ {"DOCKER_CONFIG", "GIT_ASKPASS", "NETRC", "NPM_CONFIG_USERCONFIG", "PIP_CONFIG_FILE", "SSH_ASKPASS"}
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ProcessResult:
22
+ returncode: int
23
+ stdout: str = ""
24
+ stderr: str = ""
25
+
26
+ def __post_init__(self) -> None:
27
+ if type(self.returncode) is not int:
28
+ msg = "process return code must be an integer"
29
+ raise TypeError(msg)
30
+ if type(self.stdout) is not str:
31
+ msg = "process stdout must be text"
32
+ raise TypeError(msg)
33
+ if type(self.stderr) is not str:
34
+ msg = "process stderr must be text"
35
+ raise TypeError(msg)
36
+
37
+
38
+ class ProcessRunner(Protocol):
39
+ def __call__(
40
+ self,
41
+ argv: tuple[str, ...],
42
+ *,
43
+ cwd: Path,
44
+ capture_output: bool = False,
45
+ ) -> ProcessResult: ...
46
+
47
+
48
+ class ProcessFailureError(RuntimeError):
49
+ argv: tuple[str, ...]
50
+ returncode: int
51
+
52
+ def __init__(self, argv: tuple[str, ...], returncode: int) -> None:
53
+ self.argv = argv
54
+ self.returncode = returncode
55
+ super().__init__(f"{argv[0]} {argv[1] if len(argv) > 1 else ''} failed with exit code {returncode}")
56
+
57
+
58
+ def run_process(
59
+ argv: tuple[str, ...],
60
+ *,
61
+ cwd: Path,
62
+ capture_output: bool = False,
63
+ ) -> ProcessResult:
64
+ return run_process_environment(argv, cwd=cwd, capture_output=capture_output, environment=None)
65
+
66
+
67
+ def run_process_environment(
68
+ argv: tuple[str, ...],
69
+ *,
70
+ cwd: Path,
71
+ capture_output: bool = False,
72
+ environment: Mapping[str, str] | None,
73
+ ) -> ProcessResult:
74
+ completed = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- argv is passed directly and shell remains disabled.
75
+ argv,
76
+ cwd=cwd,
77
+ check=False,
78
+ env=None if environment is None else dict(environment),
79
+ text=True,
80
+ stdout=subprocess.PIPE if capture_output else None,
81
+ stderr=None,
82
+ )
83
+ result = ProcessResult(completed.returncode, completed.stdout or "")
84
+ if result.returncode != 0:
85
+ raise ProcessFailureError(argv, result.returncode)
86
+ return result
87
+
88
+
89
+ def credential_free_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
90
+ source = os.environ if environment is None else environment # ruff: ignore[banned-api] -- deliberate child-process boundary
91
+ blocked_fragments = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "API_KEY", "AUTH")
92
+ blocked_prefixes = ("AWS_", "AZURE_", "GOOGLE_", "TWINE_", "UV_PUBLISH_", "ACTIONS_ID_TOKEN_")
93
+ return {
94
+ name: value
95
+ for name, value in source.items()
96
+ if name.upper() not in _BLOCKED_CREDENTIAL_NAMES
97
+ and not name.upper().startswith(blocked_prefixes)
98
+ and not any(fragment in name.upper() for fragment in blocked_fragments)
99
+ }
100
+
101
+
102
+ def run_build_process(
103
+ argv: tuple[str, ...],
104
+ *,
105
+ cwd: Path,
106
+ capture_output: bool = False,
107
+ ) -> ProcessResult:
108
+ with tempfile.TemporaryDirectory(prefix="sarj-build-home-") as home:
109
+ environment = credential_free_environment()
110
+ environment.update(
111
+ {
112
+ "GIT_CONFIG_GLOBAL": os.devnull,
113
+ "HOME": home,
114
+ "USERPROFILE": home,
115
+ "APPDATA": home,
116
+ "LOCALAPPDATA": home,
117
+ "NETRC": os.devnull,
118
+ "NPM_CONFIG_USERCONFIG": os.devnull,
119
+ "PIP_CONFIG_FILE": os.devnull,
120
+ "XDG_CONFIG_HOME": home,
121
+ }
122
+ )
123
+ return run_process_environment(
124
+ argv,
125
+ cwd=cwd,
126
+ capture_output=capture_output,
127
+ environment=environment,
128
+ )
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+ from types import MappingProxyType
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ from sarj_standards.libs.release.artifacts import verify_python_wheel_license
10
+ from sarj_standards.libs.release.process import ProcessRunner, run_build_process, run_process
11
+ from sarj_standards.libs.release.typescript import run_typescript_release
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+
18
+ PublishTarget = Literal["typescript", "bootstrap", "python", "sql", "iac", "standards", "tsconfig", "docs-ui"]
19
+ _EXPECTED_PYTHON_ARTIFACTS = 1
20
+ _PYTHON_TARGETS: Mapping[str, str] = MappingProxyType(
21
+ {
22
+ "bootstrap": "bootstrap",
23
+ "python": "python",
24
+ "sql": "sql",
25
+ "iac": "iac",
26
+ "standards": "standards",
27
+ }
28
+ )
29
+
30
+
31
+ def publish_target(root: Path, target: PublishTarget, *, runner: ProcessRunner = run_process) -> None:
32
+ resolved = root.resolve()
33
+ build_runner = run_build_process if runner is run_process else runner
34
+ if target == "typescript":
35
+ _ = run_typescript_release("publish", resolved / "packages" / "typescript", runner=runner)
36
+ return
37
+ if target in {"tsconfig", "docs-ui"}:
38
+ cwd = resolved / "packages" / target
39
+ with TemporaryDirectory(prefix=f"sarj-{target}-release-") as temporary:
40
+ destination = Path(temporary)
41
+ result = build_runner(
42
+ ("npm", "pack", "--json", "--pack-destination", str(destination)),
43
+ cwd=cwd,
44
+ capture_output=True,
45
+ )
46
+ artifact = destination / _npm_pack_filename(result.stdout)
47
+ if not artifact.is_file() or artifact.stat().st_size == 0:
48
+ msg = f"npm pack did not create its reported artifact: {artifact.name}"
49
+ raise ValueError(msg)
50
+ runner(("npm", "publish", str(artifact), "--access", "public", "--ignore-scripts"), cwd=cwd)
51
+ return
52
+ package = _PYTHON_TARGETS.get(target)
53
+ if package is None:
54
+ msg = f"unsupported release target: {target}"
55
+ raise ValueError(msg)
56
+ cwd = resolved / "packages" / package
57
+ with TemporaryDirectory(prefix=f"sarj-{package}-release-") as temporary:
58
+ destination = Path(temporary)
59
+ build_runner(("uv", "build", "--wheel", "--out-dir", str(destination)), cwd=cwd)
60
+ artifacts = tuple(sorted(destination.glob("*.whl")))
61
+ if len(artifacts) != _EXPECTED_PYTHON_ARTIFACTS or any(
62
+ not artifact.is_file() or artifact.stat().st_size == 0 for artifact in artifacts
63
+ ):
64
+ msg = f"uv build did not create exactly one wheel for {target}"
65
+ raise ValueError(msg)
66
+ verify_python_wheel_license(artifacts[0])
67
+ runner(("uv", "publish", *(str(artifact) for artifact in artifacts)), cwd=cwd)
68
+
69
+
70
+ def _npm_pack_filename(output: str) -> str:
71
+ decoder = json.JSONDecoder()
72
+ for index in range(len(output) - 1, -1, -1):
73
+ if output[index] != "[":
74
+ continue
75
+ try:
76
+ report, _ = decoder.raw_decode(output[index:]) # pyright: ignore[reportAny]
77
+ except json.JSONDecodeError:
78
+ continue
79
+ if not isinstance(report, list) or not report or not isinstance(report[0], dict):
80
+ continue
81
+ filename = report[0].get("filename") # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
82
+ if isinstance(filename, str) and Path(filename).name == filename and filename.endswith(".tgz"):
83
+ return filename
84
+ msg = "npm pack returned no safe artifact filename"
85
+ raise ValueError(msg)