didibreak 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. didibreak-0.1.0/.gitignore +28 -0
  2. didibreak-0.1.0/LICENSE +21 -0
  3. didibreak-0.1.0/PKG-INFO +45 -0
  4. didibreak-0.1.0/README.md +17 -0
  5. didibreak-0.1.0/hatch_build.py +58 -0
  6. didibreak-0.1.0/pyproject.toml +100 -0
  7. didibreak-0.1.0/scripts/check_dist.py +103 -0
  8. didibreak-0.1.0/scripts/evaluate.py +386 -0
  9. didibreak-0.1.0/scripts/llm_smoke.py +111 -0
  10. didibreak-0.1.0/scripts/package_smoke.py +179 -0
  11. didibreak-0.1.0/scripts/sync_prompts.py +114 -0
  12. didibreak-0.1.0/src/didibreak/__init__.py +3 -0
  13. didibreak-0.1.0/src/didibreak/__main__.py +5 -0
  14. didibreak-0.1.0/src/didibreak/_harness_child.py +280 -0
  15. didibreak-0.1.0/src/didibreak/cache.py +156 -0
  16. didibreak-0.1.0/src/didibreak/cases.py +326 -0
  17. didibreak-0.1.0/src/didibreak/cli.py +707 -0
  18. didibreak-0.1.0/src/didibreak/compare.py +276 -0
  19. didibreak-0.1.0/src/didibreak/config.py +371 -0
  20. didibreak-0.1.0/src/didibreak/contracts.py +227 -0
  21. didibreak-0.1.0/src/didibreak/functions.py +367 -0
  22. didibreak-0.1.0/src/didibreak/gitdiff.py +438 -0
  23. didibreak-0.1.0/src/didibreak/harness.py +373 -0
  24. didibreak-0.1.0/src/didibreak/intent.py +163 -0
  25. didibreak-0.1.0/src/didibreak/llm/__init__.py +122 -0
  26. didibreak-0.1.0/src/didibreak/llm/anthropic.py +175 -0
  27. didibreak-0.1.0/src/didibreak/llm/common.py +406 -0
  28. didibreak-0.1.0/src/didibreak/llm/openai_compat.py +218 -0
  29. didibreak-0.1.0/src/didibreak/llm/pricing.py +169 -0
  30. didibreak-0.1.0/src/didibreak/llm_session.py +292 -0
  31. didibreak-0.1.0/src/didibreak/pipeline.py +594 -0
  32. didibreak-0.1.0/src/didibreak/prompts/case_generation.md +31 -0
  33. didibreak-0.1.0/src/didibreak/prompts/case_refinement.md +34 -0
  34. didibreak-0.1.0/src/didibreak/prompts/intent_labelling.md +21 -0
  35. didibreak-0.1.0/src/didibreak/report/__init__.py +98 -0
  36. didibreak-0.1.0/src/didibreak/report/json.py +82 -0
  37. didibreak-0.1.0/src/didibreak/report/pytest_writer.py +357 -0
  38. didibreak-0.1.0/src/didibreak/report/text.py +156 -0
  39. didibreak-0.1.0/src/didibreak/testability.py +382 -0
  40. didibreak-0.1.0/tests/conftest.py +63 -0
  41. didibreak-0.1.0/tests/fixture_repos.py +112 -0
  42. didibreak-0.1.0/tests/fixtures/exception_type/after/app/parsing.py +5 -0
  43. didibreak-0.1.0/tests/fixtures/exception_type/before/app/parsing.py +7 -0
  44. didibreak-0.1.0/tests/fixtures/exception_type/expected.json +48 -0
  45. didibreak-0.1.0/tests/fixtures/float_precision/after/app/stats.py +11 -0
  46. didibreak-0.1.0/tests/fixtures/float_precision/before/app/stats.py +9 -0
  47. didibreak-0.1.0/tests/fixtures/float_precision/expected.json +45 -0
  48. didibreak-0.1.0/tests/fixtures/formatting_only/after/app/limits.py +10 -0
  49. didibreak-0.1.0/tests/fixtures/formatting_only/before/app/limits.py +5 -0
  50. didibreak-0.1.0/tests/fixtures/formatting_only/expected.json +19 -0
  51. didibreak-0.1.0/tests/fixtures/intended_change/after/app/greetings.py +5 -0
  52. didibreak-0.1.0/tests/fixtures/intended_change/before/app/greetings.py +5 -0
  53. didibreak-0.1.0/tests/fixtures/intended_change/expected.json +33 -0
  54. didibreak-0.1.0/tests/fixtures/new_function/after/app/text.py +14 -0
  55. didibreak-0.1.0/tests/fixtures/new_function/before/app/text.py +5 -0
  56. didibreak-0.1.0/tests/fixtures/new_function/expected.json +41 -0
  57. didibreak-0.1.0/tests/fixtures/new_function_validation/after/app/percent.py +24 -0
  58. didibreak-0.1.0/tests/fixtures/new_function_validation/before/app/percent.py +5 -0
  59. didibreak-0.1.0/tests/fixtures/new_function_validation/expected.json +19 -0
  60. didibreak-0.1.0/tests/fixtures/nondeterministic/after/app/tokens.py +7 -0
  61. didibreak-0.1.0/tests/fixtures/nondeterministic/before/app/tokens.py +7 -0
  62. didibreak-0.1.0/tests/fixtures/nondeterministic/expected.json +22 -0
  63. didibreak-0.1.0/tests/fixtures/phone_regression/after/app/utils/phone.py +9 -0
  64. didibreak-0.1.0/tests/fixtures/phone_regression/before/app/utils/phone.py +9 -0
  65. didibreak-0.1.0/tests/fixtures/phone_regression/expected.json +30 -0
  66. didibreak-0.1.0/tests/llm_responses/malicious_cases.json +16 -0
  67. didibreak-0.1.0/tests/llm_responses/openai_phone_cases.json +25 -0
  68. didibreak-0.1.0/tests/report_samples.py +95 -0
  69. didibreak-0.1.0/tests/snapshots/report.json +109 -0
  70. didibreak-0.1.0/tests/snapshots/report.txt +18 -0
  71. didibreak-0.1.0/tests/test_ai_agents_doc.py +52 -0
  72. didibreak-0.1.0/tests/test_base_worktree.py +153 -0
  73. didibreak-0.1.0/tests/test_case_cache.py +101 -0
  74. didibreak-0.1.0/tests/test_case_generation.py +309 -0
  75. didibreak-0.1.0/tests/test_case_prompt.py +187 -0
  76. didibreak-0.1.0/tests/test_case_validation.py +125 -0
  77. didibreak-0.1.0/tests/test_cases_file.py +67 -0
  78. didibreak-0.1.0/tests/test_changed_files.py +171 -0
  79. didibreak-0.1.0/tests/test_check_command.py +400 -0
  80. didibreak-0.1.0/tests/test_check_cost_and_cache.py +265 -0
  81. didibreak-0.1.0/tests/test_check_dist.py +105 -0
  82. didibreak-0.1.0/tests/test_check_end_to_end.py +191 -0
  83. didibreak-0.1.0/tests/test_check_labelling.py +226 -0
  84. didibreak-0.1.0/tests/test_check_llm_end_to_end.py +199 -0
  85. didibreak-0.1.0/tests/test_check_llm_settings.py +181 -0
  86. didibreak-0.1.0/tests/test_check_refinement.py +162 -0
  87. didibreak-0.1.0/tests/test_check_reports.py +152 -0
  88. didibreak-0.1.0/tests/test_check_reproducing_tests.py +118 -0
  89. didibreak-0.1.0/tests/test_cli.py +67 -0
  90. didibreak-0.1.0/tests/test_compare_fixtures.py +123 -0
  91. didibreak-0.1.0/tests/test_compare_rules.py +191 -0
  92. didibreak-0.1.0/tests/test_compare_values.py +83 -0
  93. didibreak-0.1.0/tests/test_config.py +178 -0
  94. didibreak-0.1.0/tests/test_contracts.py +114 -0
  95. didibreak-0.1.0/tests/test_definitions.py +115 -0
  96. didibreak-0.1.0/tests/test_docs_anthropic_label.py +25 -0
  97. didibreak-0.1.0/tests/test_docs_coverage.py +49 -0
  98. didibreak-0.1.0/tests/test_docs_precision_claim.py +42 -0
  99. didibreak-0.1.0/tests/test_evaluate_script.py +193 -0
  100. didibreak-0.1.0/tests/test_exclusions.py +69 -0
  101. didibreak-0.1.0/tests/test_explicit_raises.py +96 -0
  102. didibreak-0.1.0/tests/test_finding_id.py +56 -0
  103. didibreak-0.1.0/tests/test_fixture_repos.py +143 -0
  104. didibreak-0.1.0/tests/test_functions.py +262 -0
  105. didibreak-0.1.0/tests/test_gitdiff.py +86 -0
  106. didibreak-0.1.0/tests/test_harness.py +360 -0
  107. didibreak-0.1.0/tests/test_harness_outcomes.py +207 -0
  108. didibreak-0.1.0/tests/test_how_it_works_doc.py +27 -0
  109. didibreak-0.1.0/tests/test_import_roots.py +80 -0
  110. didibreak-0.1.0/tests/test_init_agents.py +68 -0
  111. didibreak-0.1.0/tests/test_intent_labels.py +138 -0
  112. didibreak-0.1.0/tests/test_intent_text.py +69 -0
  113. didibreak-0.1.0/tests/test_limitations_doc.py +27 -0
  114. didibreak-0.1.0/tests/test_llm_anthropic.py +167 -0
  115. didibreak-0.1.0/tests/test_llm_common.py +289 -0
  116. didibreak-0.1.0/tests/test_llm_factory.py +141 -0
  117. didibreak-0.1.0/tests/test_llm_openai_compat.py +215 -0
  118. didibreak-0.1.0/tests/test_llm_smoke.py +88 -0
  119. didibreak-0.1.0/tests/test_output_format_doc.py +57 -0
  120. didibreak-0.1.0/tests/test_package_smoke.py +67 -0
  121. didibreak-0.1.0/tests/test_pipeline.py +175 -0
  122. didibreak-0.1.0/tests/test_prices_doc.py +37 -0
  123. didibreak-0.1.0/tests/test_pricing.py +135 -0
  124. didibreak-0.1.0/tests/test_pytest_writer.py +340 -0
  125. didibreak-0.1.0/tests/test_release_workflow.py +54 -0
  126. didibreak-0.1.0/tests/test_report_json.py +86 -0
  127. didibreak-0.1.0/tests/test_report_schema.py +58 -0
  128. didibreak-0.1.0/tests/test_report_text.py +61 -0
  129. didibreak-0.1.0/tests/test_safety_doc.py +24 -0
  130. didibreak-0.1.0/tests/test_sync_prompts.py +100 -0
  131. didibreak-0.1.0/tests/test_testability.py +318 -0
  132. didibreak-0.1.0/tests/test_working_tree_copy.py +66 -0
@@ -0,0 +1,28 @@
1
+ # Python bytecode and caches
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+
8
+ # Virtual environments
9
+ .venv/
10
+
11
+ # Build output
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+
16
+ # Generated at build time from spec/prompts/ (python/scripts/sync_prompts.py)
17
+ python/src/didibreak/prompts/
18
+
19
+ # Copied from the root LICENSE at build time (python/hatch_build.py)
20
+ python/LICENSE
21
+
22
+ # didibreak output when run on this repository
23
+ .didibreak/
24
+
25
+ # OS and editor files
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Levan Changelia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: didibreak
3
+ Version: 0.1.0
4
+ Summary: Find unintended behavior changes in Python code by running the old and new versions side by side.
5
+ Project-URL: Homepage, https://github.com/changelia/didibreak
6
+ Project-URL: Documentation, https://github.com/changelia/didibreak/tree/main/docs
7
+ Project-URL: Changelog, https://github.com/changelia/didibreak/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/changelia/didibreak/issues
9
+ Author: changelia
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai agents,behavior change,differential testing,llm,regression,testing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: httpx<1,>=0.28
27
+ Description-Content-Type: text/markdown
28
+
29
+ # didibreak (Python)
30
+
31
+ didibreak finds unintended behavior changes in Python code changes. It runs the
32
+ old and new versions of each changed function on the same generated inputs and
33
+ reports where the results differ.
34
+
35
+ ```bash
36
+ pip install didibreak
37
+ export OPENAI_API_KEY="sk-..." # or ANTHROPIC_API_KEY (experimental, not live-verified)
38
+ didibreak check --intent "what the change should do"
39
+ ```
40
+
41
+ Requires Python 3.11 or newer, Linux or macOS, and git. Version 0.1.0 is the
42
+ first release (alpha). No telemetry: the only network requests go to the LLM
43
+ provider you configure, with your own API key.
44
+
45
+ Full documentation: https://github.com/changelia/didibreak/tree/main/docs
@@ -0,0 +1,17 @@
1
+ # didibreak (Python)
2
+
3
+ didibreak finds unintended behavior changes in Python code changes. It runs the
4
+ old and new versions of each changed function on the same generated inputs and
5
+ reports where the results differ.
6
+
7
+ ```bash
8
+ pip install didibreak
9
+ export OPENAI_API_KEY="sk-..." # or ANTHROPIC_API_KEY (experimental, not live-verified)
10
+ didibreak check --intent "what the change should do"
11
+ ```
12
+
13
+ Requires Python 3.11 or newer, Linux or macOS, and git. Version 0.1.0 is the
14
+ first release (alpha). No telemetry: the only network requests go to the LLM
15
+ provider you configure, with your own API key.
16
+
17
+ Full documentation: https://github.com/changelia/didibreak/tree/main/docs
@@ -0,0 +1,58 @@
1
+ """Hatch hooks that copy shared repository files into the Python project.
2
+
3
+ - The metadata hook copies the root LICENSE to python/LICENSE. It must run in
4
+ the metadata phase: Hatch resolves ``license-files`` when it builds the
5
+ metadata, before any build hook runs.
6
+ - The build hook copies spec/prompts/ into src/didibreak/prompts/ before each
7
+ sdist, wheel and editable build.
8
+
9
+ When building from an extracted sdist there is no repository around the
10
+ project; the sdist already contains both copies, so the hooks leave them as
11
+ they are.
12
+ """
13
+
14
+ import importlib.util
15
+ import shutil
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+ from typing import Any
19
+
20
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
21
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
22
+
23
+
24
+ def _load_sync_prompts(project_root: Path) -> ModuleType:
25
+ script = project_root / "scripts" / "sync_prompts.py"
26
+ module_spec = importlib.util.spec_from_file_location("sync_prompts", script)
27
+ if module_spec is None or module_spec.loader is None:
28
+ raise RuntimeError(f"Cannot load {script}")
29
+ module = importlib.util.module_from_spec(module_spec)
30
+ module_spec.loader.exec_module(module)
31
+ return module
32
+
33
+
34
+ class LicenseCopyMetadataHook(MetadataHookInterface):
35
+ """Copy the repository LICENSE so ``license-files`` can find it."""
36
+
37
+ PLUGIN_NAME = "custom"
38
+
39
+ def update(self, metadata: dict[str, Any]) -> None:
40
+ """Copy the license; the metadata itself is left unchanged."""
41
+ project_root = Path(self.root)
42
+ repository_license = project_root.parent / "LICENSE"
43
+ if repository_license.is_file():
44
+ shutil.copyfile(repository_license, project_root / "LICENSE")
45
+
46
+
47
+ class PromptCopyBuildHook(BuildHookInterface):
48
+ """Copy the shared prompt templates into src/didibreak/prompts/."""
49
+
50
+ PLUGIN_NAME = "custom"
51
+
52
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
53
+ """Copy the prompts before Hatch collects the files to build."""
54
+ sync_prompts = _load_sync_prompts(Path(self.root))
55
+ if sync_prompts.SPEC_PROMPTS.is_dir():
56
+ sync_prompts.copy_prompts(
57
+ sync_prompts.SPEC_PROMPTS, sync_prompts.PACKAGE_PROMPTS
58
+ )
@@ -0,0 +1,100 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "didibreak"
7
+ dynamic = ["version"]
8
+ description = "Find unintended behavior changes in Python code by running the old and new versions side by side."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "changelia" }]
14
+ keywords = ["behavior change", "regression", "testing", "llm", "ai agents", "differential testing"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Quality Assurance",
27
+ "Topic :: Software Development :: Testing",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.28,<1",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/changelia/didibreak"
35
+ Documentation = "https://github.com/changelia/didibreak/tree/main/docs"
36
+ Changelog = "https://github.com/changelia/didibreak/blob/main/CHANGELOG.md"
37
+ Issues = "https://github.com/changelia/didibreak/issues"
38
+
39
+ [project.scripts]
40
+ didibreak = "didibreak.cli:main"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest",
45
+ "ruff",
46
+ "mypy",
47
+ "jsonschema==4.26.0", # dev only: validates reports against spec/report.schema.json
48
+ ]
49
+
50
+ [tool.ruff]
51
+ target-version = "py311"
52
+ # Fixture repositories hold sample user code, some of it deliberately
53
+ # unformatted; they are data, not project code.
54
+ extend-exclude = ["tests/fixtures"]
55
+
56
+ [tool.ruff.lint]
57
+ # Rule set from the Code quality standards.
58
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "D"]
59
+
60
+ [tool.ruff.lint.pydocstyle]
61
+ convention = "google"
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ # Test names describe the behavior; docstrings on each test add nothing.
65
+ "tests/**" = ["D1"]
66
+
67
+ [tool.mypy]
68
+ strict = true
69
+ python_version = "3.11"
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests"]
73
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
74
+ xfail_strict = true
75
+
76
+ [tool.hatch.version]
77
+ path = "src/didibreak/__init__.py"
78
+
79
+ [tool.hatch.build]
80
+ # Copied from the repository at build time (git-ignored) but must be packaged.
81
+ artifacts = ["src/didibreak/prompts/", "/LICENSE"]
82
+
83
+ [tool.hatch.metadata.hooks.custom]
84
+ # hatch_build.py: copies the root LICENSE to python/LICENSE.
85
+
86
+ [tool.hatch.build.hooks.custom]
87
+ # hatch_build.py: copies spec/prompts/ into src/didibreak/prompts/.
88
+
89
+ [tool.hatch.build.targets.wheel]
90
+ packages = ["src/didibreak"]
91
+
92
+ [tool.hatch.build.targets.sdist]
93
+ include = [
94
+ "src/didibreak",
95
+ "tests",
96
+ "scripts",
97
+ "hatch_build.py",
98
+ "README.md",
99
+ "LICENSE",
100
+ ]
@@ -0,0 +1,103 @@
1
+ """Check the contents of the built wheel and sdist in a dist/ folder.
2
+
3
+ The packages must contain only the Python package (no spec/, docs/ or JS
4
+ files, except the copied prompts) and must ship the license. CI runs this
5
+ after ``uv build``; it uses only the standard library.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ import tarfile
11
+ import zipfile
12
+ from collections.abc import Sequence
13
+ from pathlib import Path
14
+
15
+ _FORBIDDEN_SDIST_FOLDERS = ("spec/", "docs/", "js/")
16
+
17
+
18
+ def check_wheel(wheel: Path) -> list[str]:
19
+ """List problems in a wheel; empty when it is correct.
20
+
21
+ Args:
22
+ wheel: Path to the ``.whl`` file.
23
+
24
+ Returns:
25
+ One human-readable line per problem.
26
+ """
27
+ with zipfile.ZipFile(wheel) as archive:
28
+ names = archive.namelist()
29
+ problems = [
30
+ f"unexpected file in wheel: {name}"
31
+ for name in names
32
+ if not name.startswith("didibreak/") and ".dist-info/" not in name
33
+ ]
34
+ if not any(name.endswith(".dist-info/licenses/LICENSE") for name in names):
35
+ problems.append("wheel has no .dist-info/licenses/LICENSE")
36
+ return problems
37
+
38
+
39
+ def check_sdist(sdist: Path) -> list[str]:
40
+ """List problems in an sdist; empty when it is correct.
41
+
42
+ Args:
43
+ sdist: Path to the ``.tar.gz`` file.
44
+
45
+ Returns:
46
+ One human-readable line per problem.
47
+ """
48
+ with tarfile.open(sdist) as archive:
49
+ members = archive.getmembers()
50
+ pkg_info = next((m for m in members if m.name.endswith("/PKG-INFO")), None)
51
+ metadata = ""
52
+ if pkg_info is not None:
53
+ extracted = archive.extractfile(pkg_info)
54
+ if extracted is not None:
55
+ metadata = extracted.read().decode("utf-8")
56
+ # Member names start with the "<name>-<version>/" folder.
57
+ relative_names = [m.name.partition("/")[2] for m in members]
58
+ problems = [
59
+ f"unexpected file in sdist: {name}"
60
+ for name in relative_names
61
+ if name.startswith(_FORBIDDEN_SDIST_FOLDERS)
62
+ ]
63
+ if "LICENSE" not in relative_names:
64
+ problems.append("sdist has no LICENSE")
65
+ if "License-File: LICENSE" not in metadata.splitlines():
66
+ problems.append("sdist PKG-INFO has no 'License-File: LICENSE'")
67
+ return problems
68
+
69
+
70
+ def main(argv: Sequence[str] | None = None) -> int:
71
+ """Check the single wheel and sdist in the given folder.
72
+
73
+ Args:
74
+ argv: Arguments without the program name. Defaults to ``sys.argv[1:]``.
75
+
76
+ Returns:
77
+ 0 when both archives are correct, 1 otherwise.
78
+ """
79
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
80
+ parser.add_argument("dist_dir", type=Path, help="folder written by uv build")
81
+ arguments = parser.parse_args(argv)
82
+
83
+ wheels = sorted(arguments.dist_dir.glob("*.whl"))
84
+ sdists = sorted(arguments.dist_dir.glob("*.tar.gz"))
85
+ if len(wheels) != 1 or len(sdists) != 1:
86
+ print(
87
+ f"Expected one wheel and one sdist in {arguments.dist_dir}, "
88
+ f"found {len(wheels)} and {len(sdists)}.",
89
+ file=sys.stderr,
90
+ )
91
+ return 1
92
+
93
+ problems = check_wheel(wheels[0]) + check_sdist(sdists[0])
94
+ for problem in problems:
95
+ print(problem, file=sys.stderr)
96
+ if problems:
97
+ return 1
98
+ print(f"OK: {wheels[0].name}, {sdists[0].name}")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())