twinbox 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 (214) hide show
  1. twinbox-0.1.0/.dockerignore +9 -0
  2. twinbox-0.1.0/.gitignore +10 -0
  3. twinbox-0.1.0/.gitlab-ci.yml +120 -0
  4. twinbox-0.1.0/CONTRIBUTING.md +119 -0
  5. twinbox-0.1.0/Dockerfile.test +13 -0
  6. twinbox-0.1.0/LICENSE +21 -0
  7. twinbox-0.1.0/PKG-INFO +323 -0
  8. twinbox-0.1.0/README.md +289 -0
  9. twinbox-0.1.0/README.ru.md +288 -0
  10. twinbox-0.1.0/Taskfile.yml +45 -0
  11. twinbox-0.1.0/docker-compose.test.yml +14 -0
  12. twinbox-0.1.0/docs/logo.png +0 -0
  13. twinbox-0.1.0/docs/usage.md +560 -0
  14. twinbox-0.1.0/pyproject.toml +141 -0
  15. twinbox-0.1.0/src/twinbox/__init__.py +1 -0
  16. twinbox-0.1.0/src/twinbox/comparison/__init__.py +1 -0
  17. twinbox-0.1.0/src/twinbox/comparison/expectation.py +41 -0
  18. twinbox-0.1.0/src/twinbox/comparison/masks.py +419 -0
  19. twinbox-0.1.0/src/twinbox/comparison/response.py +351 -0
  20. twinbox-0.1.0/src/twinbox/coverage/__init__.py +1 -0
  21. twinbox-0.1.0/src/twinbox/coverage/matrix.py +212 -0
  22. twinbox-0.1.0/src/twinbox/errors.py +29 -0
  23. twinbox-0.1.0/src/twinbox/execution/__init__.py +1 -0
  24. twinbox-0.1.0/src/twinbox/execution/capture.py +168 -0
  25. twinbox-0.1.0/src/twinbox/execution/mock_calls.py +245 -0
  26. twinbox-0.1.0/src/twinbox/execution/placeholders.py +493 -0
  27. twinbox-0.1.0/src/twinbox/execution/request.py +284 -0
  28. twinbox-0.1.0/src/twinbox/execution/run.py +620 -0
  29. twinbox-0.1.0/src/twinbox/harness/__init__.py +27 -0
  30. twinbox-0.1.0/src/twinbox/harness/database.py +490 -0
  31. twinbox-0.1.0/src/twinbox/harness/docker_host.py +16 -0
  32. twinbox-0.1.0/src/twinbox/harness/env_pairs.py +24 -0
  33. twinbox-0.1.0/src/twinbox/harness/mock.py +328 -0
  34. twinbox-0.1.0/src/twinbox/harness/network.py +73 -0
  35. twinbox-0.1.0/src/twinbox/harness/profile.py +231 -0
  36. twinbox-0.1.0/src/twinbox/harness/sut.py +377 -0
  37. twinbox-0.1.0/src/twinbox/launch/__init__.py +1 -0
  38. twinbox-0.1.0/src/twinbox/launch/cli.py +607 -0
  39. twinbox-0.1.0/src/twinbox/launch/cli_help.py +169 -0
  40. twinbox-0.1.0/src/twinbox/launch/corpus.py +67 -0
  41. twinbox-0.1.0/src/twinbox/launch/extensions.py +335 -0
  42. twinbox-0.1.0/src/twinbox/launch/plugin.py +865 -0
  43. twinbox-0.1.0/src/twinbox/launch/registry.py +65 -0
  44. twinbox-0.1.0/src/twinbox/launch/settings.py +150 -0
  45. twinbox-0.1.0/src/twinbox/launch/stand.py +768 -0
  46. twinbox-0.1.0/src/twinbox/launch/worker_results.py +237 -0
  47. twinbox-0.1.0/src/twinbox/load/__init__.py +35 -0
  48. twinbox-0.1.0/src/twinbox/load/acceptance.py +126 -0
  49. twinbox-0.1.0/src/twinbox/load/context.py +66 -0
  50. twinbox-0.1.0/src/twinbox/load/cpu_benchmark.py +282 -0
  51. twinbox-0.1.0/src/twinbox/load/errors.py +9 -0
  52. twinbox-0.1.0/src/twinbox/load/metrics.py +213 -0
  53. twinbox-0.1.0/src/twinbox/load/network_measurement.py +432 -0
  54. twinbox-0.1.0/src/twinbox/load/numbers.py +100 -0
  55. twinbox-0.1.0/src/twinbox/load/output.py +123 -0
  56. twinbox-0.1.0/src/twinbox/load/population.py +88 -0
  57. twinbox-0.1.0/src/twinbox/load/run.py +735 -0
  58. twinbox-0.1.0/src/twinbox/load/settings.py +261 -0
  59. twinbox-0.1.0/src/twinbox/load/stand.py +263 -0
  60. twinbox-0.1.0/src/twinbox/outcome/__init__.py +1 -0
  61. twinbox-0.1.0/src/twinbox/outcome/allowance.py +215 -0
  62. twinbox-0.1.0/src/twinbox/outcome/verdict.py +308 -0
  63. twinbox-0.1.0/src/twinbox/py.typed +0 -0
  64. twinbox-0.1.0/src/twinbox/reports/__init__.py +5 -0
  65. twinbox-0.1.0/src/twinbox/reports/artifacts.py +74 -0
  66. twinbox-0.1.0/src/twinbox/reports/step_result.py +713 -0
  67. twinbox-0.1.0/src/twinbox/reports/trace.py +121 -0
  68. twinbox-0.1.0/src/twinbox/scenario/__init__.py +1 -0
  69. twinbox-0.1.0/src/twinbox/scenario/fields.py +344 -0
  70. twinbox-0.1.0/src/twinbox/scenario/layout.py +77 -0
  71. twinbox-0.1.0/src/twinbox/scenario/tags.py +133 -0
  72. twinbox-0.1.0/src/twinbox/scenario/validation.py +956 -0
  73. twinbox-0.1.0/src/twinbox/yamlio.py +150 -0
  74. twinbox-0.1.0/src/twinbox_mock/Dockerfile +14 -0
  75. twinbox-0.1.0/src/twinbox_mock/__init__.py +1 -0
  76. twinbox-0.1.0/src/twinbox_mock/__main__.py +35 -0
  77. twinbox-0.1.0/src/twinbox_mock/app.py +28 -0
  78. twinbox-0.1.0/src/twinbox_mock/control_api.py +70 -0
  79. twinbox-0.1.0/src/twinbox_mock/journal.py +64 -0
  80. twinbox-0.1.0/src/twinbox_mock/matching.py +39 -0
  81. twinbox-0.1.0/src/twinbox_mock/program.py +177 -0
  82. twinbox-0.1.0/src/twinbox_mock/py.typed +0 -0
  83. twinbox-0.1.0/src/twinbox_mock/response.py +119 -0
  84. twinbox-0.1.0/src/twinbox_mock/state.py +41 -0
  85. twinbox-0.1.0/tests/conftest.py +3 -0
  86. twinbox-0.1.0/tests/docker/conftest.py +79 -0
  87. twinbox-0.1.0/tests/docker/test_database_lifecycle.py +153 -0
  88. twinbox-0.1.0/tests/docker/test_ensure_image.py +34 -0
  89. twinbox-0.1.0/tests/docker/test_load_metrics_resource_bounds.py +84 -0
  90. twinbox-0.1.0/tests/docker/test_load_run_parity.py +181 -0
  91. twinbox-0.1.0/tests/docker/test_mock_control_api.py +89 -0
  92. twinbox-0.1.0/tests/docker/test_raise_mock_reachability.py +59 -0
  93. twinbox-0.1.0/tests/docker/test_run_parity.py +246 -0
  94. twinbox-0.1.0/tests/docker/test_sut_lifecycle.py +122 -0
  95. twinbox-0.1.0/tests/fixtures/basic/coverage.md +22 -0
  96. twinbox-0.1.0/tests/fixtures/basic/project_plugin.py +106 -0
  97. twinbox-0.1.0/tests/fixtures/basic/pyproject.toml +4 -0
  98. twinbox-0.1.0/tests/fixtures/basic/scenarios/health/ping/scenario.yaml +18 -0
  99. twinbox-0.1.0/tests/fixtures/basic/scenarios/payments/refund_check/mocks.yaml +11 -0
  100. twinbox-0.1.0/tests/fixtures/basic/scenarios/payments/refund_check/scenario.yaml +43 -0
  101. twinbox-0.1.0/tests/fixtures/basic/scenarios/users/create_and_fetch/scenario.yaml +41 -0
  102. twinbox-0.1.0/tests/fixtures/basic/scenarios/webhooks/relay/config/app.conf +1 -0
  103. twinbox-0.1.0/tests/fixtures/basic/scenarios/webhooks/relay/scenario.yaml +21 -0
  104. twinbox-0.1.0/tests/fixtures/broken/matrix.md +13 -0
  105. twinbox-0.1.0/tests/fixtures/broken/project_plugin.py +106 -0
  106. twinbox-0.1.0/tests/fixtures/broken/pyproject.toml +4 -0
  107. twinbox-0.1.0/tests/fixtures/broken/scenarios/ambiguous_body_mode/scenario.yaml +10 -0
  108. twinbox-0.1.0/tests/fixtures/broken/scenarios/config_dir_outside_scenario/scenario.yaml +8 -0
  109. twinbox-0.1.0/tests/fixtures/broken/scenarios/conflicting_allowance_forms/scenario.yaml +13 -0
  110. twinbox-0.1.0/tests/fixtures/broken/scenarios/divergence_on_reference/scenario.yaml +9 -0
  111. twinbox-0.1.0/tests/fixtures/broken/scenarios/duplicate_capture_name/scenario.yaml +15 -0
  112. twinbox-0.1.0/tests/fixtures/broken/scenarios/env_var_missing/scenario.yaml +10 -0
  113. twinbox-0.1.0/tests/fixtures/broken/scenarios/incompatible_tags/scenario.yaml +6 -0
  114. twinbox-0.1.0/tests/fixtures/broken/scenarios/invalid_field_value/scenario.yaml +9 -0
  115. twinbox-0.1.0/tests/fixtures/broken/scenarios/label_incompatible_with_allowance/scenario.yaml +9 -0
  116. twinbox-0.1.0/tests/fixtures/broken/scenarios/malformed_yaml/scenario.yaml +3 -0
  117. twinbox-0.1.0/tests/fixtures/broken/scenarios/mask_forbidden_here/scenario.yaml +9 -0
  118. twinbox-0.1.0/tests/fixtures/broken/scenarios/missing_required_field/scenario.yaml +5 -0
  119. twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_invalid/mocks.yaml +3 -0
  120. twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_invalid/scenario.yaml +6 -0
  121. twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_malformed/mocks.yaml +1 -0
  122. twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_malformed/scenario.yaml +6 -0
  123. twinbox-0.1.0/tests/fixtures/broken/scenarios/multiple_errors/scenario.yaml +7 -0
  124. twinbox-0.1.0/tests/fixtures/broken/scenarios/not_allowed_here/scenario.yaml +8 -0
  125. twinbox-0.1.0/tests/fixtures/broken/scenarios/reason_missing/scenario.yaml +10 -0
  126. twinbox-0.1.0/tests/fixtures/broken/scenarios/setup_step_kind_ambiguous/scenario.yaml +10 -0
  127. twinbox-0.1.0/tests/fixtures/broken/scenarios/sql_setup_not_enabled/scenario.yaml +8 -0
  128. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_capture_reference/scenario.yaml +6 -0
  129. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_field/scenario.yaml +7 -0
  130. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_implementation/scenario.yaml +10 -0
  131. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_placeholder/scenario.yaml +6 -0
  132. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_tag/scenario.yaml +6 -0
  133. twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_target/scenario.yaml +6 -0
  134. twinbox-0.1.0/tests/fixtures/parity/load/read.py +28 -0
  135. twinbox-0.1.0/tests/fixtures/parity/load/write.py +19 -0
  136. twinbox-0.1.0/tests/fixtures/parity/migration/Dockerfile +10 -0
  137. twinbox-0.1.0/tests/fixtures/parity/migration/schema.sql +7 -0
  138. twinbox-0.1.0/tests/fixtures/parity/plugin.py +201 -0
  139. twinbox-0.1.0/tests/fixtures/parity/pyproject.toml +23 -0
  140. twinbox-0.1.0/tests/fixtures/parity/reference/Dockerfile +13 -0
  141. twinbox-0.1.0/tests/fixtures/parity/reference/app.py +119 -0
  142. twinbox-0.1.0/tests/fixtures/parity/replacement/Dockerfile +22 -0
  143. twinbox-0.1.0/tests/fixtures/parity/replacement/go.mod +5 -0
  144. twinbox-0.1.0/tests/fixtures/parity/replacement/main.go +210 -0
  145. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/health/probe_with_diagnostics/scenario.yaml +15 -0
  146. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/create_and_read/scenario.yaml +27 -0
  147. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/read_extra_field/scenario.yaml +35 -0
  148. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/setup_via_api/scenario.yaml +23 -0
  149. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/setup_via_sql/scenario.yaml +15 -0
  150. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/notify/relay_through_mock/mocks.yaml +7 -0
  151. twinbox-0.1.0/tests/fixtures/parity/scenario_tests/notify/relay_through_mock/scenario.yaml +23 -0
  152. twinbox-0.1.0/tests/integration/test_fixture_projects.py +316 -0
  153. twinbox-0.1.0/tests/unit/conftest.py +48 -0
  154. twinbox-0.1.0/tests/unit/fixtures/load/no_aggregated_stats.csv +2 -0
  155. twinbox-0.1.0/tests/unit/fixtures/load/step1_stats.csv +3 -0
  156. twinbox-0.1.0/tests/unit/fixtures/load/step2_failing_stats.csv +3 -0
  157. twinbox-0.1.0/tests/unit/fixtures/load/step2_stats.csv +3 -0
  158. twinbox-0.1.0/tests/unit/test_allowance.py +240 -0
  159. twinbox-0.1.0/tests/unit/test_app.py +53 -0
  160. twinbox-0.1.0/tests/unit/test_artifacts.py +75 -0
  161. twinbox-0.1.0/tests/unit/test_capture.py +164 -0
  162. twinbox-0.1.0/tests/unit/test_cli.py +980 -0
  163. twinbox-0.1.0/tests/unit/test_control_api.py +206 -0
  164. twinbox-0.1.0/tests/unit/test_corpus.py +97 -0
  165. twinbox-0.1.0/tests/unit/test_docker_host.py +23 -0
  166. twinbox-0.1.0/tests/unit/test_english_only.py +71 -0
  167. twinbox-0.1.0/tests/unit/test_errors.py +50 -0
  168. twinbox-0.1.0/tests/unit/test_extensions.py +397 -0
  169. twinbox-0.1.0/tests/unit/test_fields.py +541 -0
  170. twinbox-0.1.0/tests/unit/test_harness_database.py +676 -0
  171. twinbox-0.1.0/tests/unit/test_harness_env_pairs.py +32 -0
  172. twinbox-0.1.0/tests/unit/test_harness_mock.py +440 -0
  173. twinbox-0.1.0/tests/unit/test_harness_network.py +90 -0
  174. twinbox-0.1.0/tests/unit/test_harness_sut.py +424 -0
  175. twinbox-0.1.0/tests/unit/test_journal.py +105 -0
  176. twinbox-0.1.0/tests/unit/test_layout.py +96 -0
  177. twinbox-0.1.0/tests/unit/test_load_acceptance.py +200 -0
  178. twinbox-0.1.0/tests/unit/test_load_context.py +60 -0
  179. twinbox-0.1.0/tests/unit/test_load_cpu_benchmark.py +365 -0
  180. twinbox-0.1.0/tests/unit/test_load_import.py +29 -0
  181. twinbox-0.1.0/tests/unit/test_load_metrics.py +329 -0
  182. twinbox-0.1.0/tests/unit/test_load_network_measurement.py +533 -0
  183. twinbox-0.1.0/tests/unit/test_load_numbers.py +132 -0
  184. twinbox-0.1.0/tests/unit/test_load_output.py +206 -0
  185. twinbox-0.1.0/tests/unit/test_load_population.py +101 -0
  186. twinbox-0.1.0/tests/unit/test_load_run.py +1324 -0
  187. twinbox-0.1.0/tests/unit/test_load_settings.py +221 -0
  188. twinbox-0.1.0/tests/unit/test_load_stand.py +678 -0
  189. twinbox-0.1.0/tests/unit/test_masks.py +320 -0
  190. twinbox-0.1.0/tests/unit/test_matching.py +60 -0
  191. twinbox-0.1.0/tests/unit/test_matrix.py +231 -0
  192. twinbox-0.1.0/tests/unit/test_mock_calls.py +372 -0
  193. twinbox-0.1.0/tests/unit/test_mock_main.py +58 -0
  194. twinbox-0.1.0/tests/unit/test_mock_response.py +268 -0
  195. twinbox-0.1.0/tests/unit/test_placeholders.py +594 -0
  196. twinbox-0.1.0/tests/unit/test_plugin.py +749 -0
  197. twinbox-0.1.0/tests/unit/test_plugin_xdist.py +658 -0
  198. twinbox-0.1.0/tests/unit/test_profile.py +252 -0
  199. twinbox-0.1.0/tests/unit/test_program.py +261 -0
  200. twinbox-0.1.0/tests/unit/test_registry.py +76 -0
  201. twinbox-0.1.0/tests/unit/test_request.py +378 -0
  202. twinbox-0.1.0/tests/unit/test_response.py +779 -0
  203. twinbox-0.1.0/tests/unit/test_run.py +882 -0
  204. twinbox-0.1.0/tests/unit/test_settings.py +171 -0
  205. twinbox-0.1.0/tests/unit/test_stand.py +1020 -0
  206. twinbox-0.1.0/tests/unit/test_state.py +50 -0
  207. twinbox-0.1.0/tests/unit/test_step_result.py +610 -0
  208. twinbox-0.1.0/tests/unit/test_tags.py +115 -0
  209. twinbox-0.1.0/tests/unit/test_trace.py +162 -0
  210. twinbox-0.1.0/tests/unit/test_validation.py +807 -0
  211. twinbox-0.1.0/tests/unit/test_verdict.py +320 -0
  212. twinbox-0.1.0/tests/unit/test_worker_results.py +306 -0
  213. twinbox-0.1.0/tests/unit/test_yamlio.py +86 -0
  214. twinbox-0.1.0/uv.lock +2194 -0
@@ -0,0 +1,9 @@
1
+ .agent-venv
2
+ .venv
3
+ .git
4
+ __pycache__
5
+ *.pyc
6
+ .pytest_cache
7
+ .mypy_cache
8
+ .ruff_cache
9
+ dist
@@ -0,0 +1,10 @@
1
+ .agent-venv/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ .claude/
10
+ .uv-cache/
@@ -0,0 +1,120 @@
1
+ stages:
2
+ - check
3
+ - build
4
+ - publish
5
+
6
+ workflow:
7
+ rules:
8
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
9
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
10
+ - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
11
+
12
+ # Same uv version as Dockerfile.test, so a pipeline job and a local
13
+ # `docker compose ... run tests` install from an identical resolver.
14
+ # trixie: no bookworm image exists for this pinned uv version.
15
+ .check_template:
16
+ image: ghcr.io/astral-sh/uv:0.11.26-python${PYTHON_VERSION}-trixie-slim
17
+ variables:
18
+ UV_CACHE_DIR: .uv-cache
19
+ UV_LINK_MODE: copy
20
+ UV_PROJECT_ENVIRONMENT: .venv
21
+ before_script:
22
+ - uv sync --locked
23
+ # Keyed on uv.lock content and the interpreter version, so a dependency
24
+ # change invalidates the cache but re-running the same lockfile does not.
25
+ cache:
26
+ key:
27
+ files:
28
+ - uv.lock
29
+ prefix: ${PYTHON_VERSION}
30
+ paths:
31
+ - .uv-cache/
32
+ after_script:
33
+ - uv cache prune --ci
34
+
35
+ format:
36
+ extends: .check_template
37
+ stage: check
38
+ parallel:
39
+ matrix:
40
+ - PYTHON_VERSION: ["3.13", "3.14"]
41
+ script:
42
+ - uv run --locked ruff format --check .
43
+
44
+ lint:
45
+ extends: .check_template
46
+ stage: check
47
+ parallel:
48
+ matrix:
49
+ - PYTHON_VERSION: ["3.13", "3.14"]
50
+ script:
51
+ - uv run --locked ruff check .
52
+
53
+ types:
54
+ extends: .check_template
55
+ stage: check
56
+ parallel:
57
+ matrix:
58
+ - PYTHON_VERSION: ["3.13", "3.14"]
59
+ script:
60
+ - uv run --locked mypy .
61
+
62
+ tests:
63
+ extends: .check_template
64
+ stage: check
65
+ parallel:
66
+ matrix:
67
+ - PYTHON_VERSION: ["3.13", "3.14"]
68
+ script:
69
+ - uv run --locked pytest
70
+
71
+ build:
72
+ extends: .check_template
73
+ stage: build
74
+ variables:
75
+ PYTHON_VERSION: "3.13"
76
+ needs:
77
+ - format
78
+ - lint
79
+ - types
80
+ - tests
81
+ rules:
82
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
83
+ - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
84
+ script:
85
+ # A version once uploaded to PyPI cannot be replaced, so a tag/version
86
+ # mismatch must fail here, before a build that would otherwise succeed.
87
+ - |
88
+ if [ -n "$CI_COMMIT_TAG" ]; then
89
+ project_version="$(uv version --short)"
90
+ tag_version="${CI_COMMIT_TAG#v}"
91
+ if [ "$tag_version" != "$project_version" ]; then
92
+ echo "tag ${CI_COMMIT_TAG} does not match project version ${project_version} in pyproject.toml" >&2
93
+ exit 1
94
+ fi
95
+ fi
96
+ - uv build
97
+ artifacts:
98
+ paths:
99
+ - dist/
100
+
101
+ publish:
102
+ stage: publish
103
+ image: ghcr.io/astral-sh/uv:0.11.26-python3.13-trixie-slim
104
+ needs:
105
+ - job: build
106
+ artifacts: true
107
+ id_tokens:
108
+ PYPI_ID_TOKEN:
109
+ aud: pypi
110
+ environment:
111
+ name: release
112
+ url: https://pypi.org/project/twinbox/
113
+ rules:
114
+ - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
115
+ when: manual
116
+ script:
117
+ # `always`, not the automatic default: without a trusted publisher
118
+ # configured on PyPI this must fail on token exchange, not fall back to
119
+ # an interactive login prompt that a CI job can never answer.
120
+ - uv publish --trusted-publishing always
@@ -0,0 +1,119 @@
1
+ # Developing twinbox
2
+
3
+ ## Setup
4
+
5
+ Everything runs through [uv](https://docs.astral.sh/uv/); do not use `pip` or
6
+ the system Python directly.
7
+
8
+ ```sh
9
+ uv sync # install dependencies and dev tools into the project environment
10
+ task check # the full gate, see below
11
+ ```
12
+
13
+ [go-task](https://taskfile.dev/) is optional: each task in `Taskfile.yml` is a
14
+ plain `uv run ...` command you can run by hand.
15
+
16
+ ## Checks
17
+
18
+ `task check` must pass before a change is merged. It runs:
19
+
20
+ | Step | Command |
21
+ | --- | --- |
22
+ | formatting | `uv run ruff format --check .` |
23
+ | lint (every ruff rule enabled) | `uv run ruff check .` |
24
+ | types | `uv run mypy .` (strict mode, pydantic plugin) |
25
+ | tests | `uv run pytest` |
26
+
27
+ ## CI
28
+
29
+ GitLab CI runs on merge requests (whatever their target branch), on commits
30
+ to `main`, and on tags matching `vX.Y.Z`. A plain branch push without an open
31
+ merge request does not start a pipeline.
32
+
33
+ The `check` stage runs the four commands above, each as its own job, on
34
+ Python 3.13 and 3.14 — eight jobs in total, so a failing job's name always
35
+ shows which check and which interpreter failed. `uv sync --locked` runs
36
+ before every check job and before `build`; an out-of-date `uv.lock` fails
37
+ the pipeline instead of being resolved on the fly. Docker tests are skipped
38
+ in CI for the same reason they are skipped locally without a daemon: no
39
+ Docker socket is available to the runner.
40
+
41
+ On `main` and on release tags, once every `check` job is green, `build`
42
+ compares the tag against the project version (tags only) and runs `uv
43
+ build`; the resulting `dist/` is downloadable from the pipeline page. A tag
44
+ that does not match `pyproject.toml` fails `build` before anything is
45
+ uploaded, since a version once on PyPI cannot be replaced.
46
+
47
+ Releasing a version:
48
+
49
+ 1. On an up-to-date, clean `main`, run `task release VERSION=0.1.0`. It sets
50
+ the version in `pyproject.toml` and `uv.lock` (`uv version`), commits it and
51
+ creates the GPG-signed tag `v0.1.0`, so the tag always matches the version.
52
+ 2. Push the commit, wait for a green `main` pipeline, then push the tag:
53
+ `git push && git push origin v0.1.0`.
54
+ 3. On that tag's pipeline, start the manual `publish` job.
55
+
56
+ `publish` uploads to PyPI through trusted publishing — no password or
57
+ long-lived token lives in the repository or in the project's CI/CD
58
+ settings. Before the first release, the project owner must configure a
59
+ trusted publisher on PyPI for project `twinbox` with GitLab path
60
+ `twinbox-group/twinbox`, workflow file `.gitlab-ci.yml` and environment
61
+ `release`.
62
+
63
+ ## Tests
64
+
65
+ | Directory | What it covers | Needs Docker |
66
+ | --- | --- | --- |
67
+ | `tests/unit` | individual modules | no |
68
+ | `tests/integration` | `twinbox check` on `tests/fixtures/basic` and `tests/fixtures/broken` | no |
69
+ | `tests/docker` | image builds, containers, the mock, full runs of `tests/fixtures/parity` | yes |
70
+
71
+ Tests in `tests/docker` carry the `docker` marker and are skipped with a
72
+ clear reason when no Docker daemon is reachable, so `uv run pytest` works
73
+ anywhere.
74
+
75
+ To run them against a real daemon, from the repository root:
76
+
77
+ ```sh
78
+ docker compose -f docker-compose.test.yml run --rm --build tests
79
+ ```
80
+
81
+ This builds a separate image with the library and uv, mounts the host's
82
+ Docker socket (`/var/run/docker.sock`) into it and runs
83
+ `uv run pytest -m docker -v`.
84
+
85
+ ## Platform support
86
+
87
+ - **Linux with Docker Engine** — the Docker test suite has been run and
88
+ passes.
89
+ - **macOS and Windows (Docker Desktop)** — not tested. The socket path and the
90
+ behaviour of `host.docker.internal` differ there, and
91
+ `docker-compose.test.yml` does not account for that.
92
+
93
+ ## Notes on `tests/fixtures/parity`
94
+
95
+ - The full run in `tests/docker/test_run_parity.py` builds the migration
96
+ image under the fixed tag `twinbox-parity-migration:local`:
97
+ `MigrationLaunch.image` in `tests/fixtures/parity/plugin.py` has no per-run
98
+ override.
99
+ - `replacement/go.mod` pins `github.com/jackc/pgx/v5 v5.6.0`, and the
100
+ `Dockerfile` pins `golang:1.22.5-bookworm`, but `go.sum` is not committed.
101
+ The image build fills it in (`go mod download`, `go build -mod=mod`), so the
102
+ first build needs network access to the Go module proxy, and the module
103
+ checksums are whatever the proxy returns at build time.
104
+
105
+ ## Repository layout
106
+
107
+ | Path | Content |
108
+ | --- | --- |
109
+ | `src/twinbox` | the library: scenario format, comparison, execution, test environment, reports, CLI and pytest plugin |
110
+ | `src/twinbox_mock` | the mock HTTP server; runs in its own container and does not import `twinbox` |
111
+ | `tests/` | the test suite, see above; `tests/fixtures` holds the fixture projects it runs against |
112
+
113
+ ## Conventions
114
+
115
+ - Code identifiers, docstrings and comments are in English.
116
+ - Messages shown to users (errors, CLI help, the HTML report) and the fixture
117
+ projects in `tests/fixtures/` are in English; `tests/unit/test_english_only.py`
118
+ fails on any Cyrillic text in `src/` or `tests/fixtures/`.
119
+ - Contributions are accepted under the project's [MIT License](LICENSE).
@@ -0,0 +1,13 @@
1
+ # Runs the docker-marked test suite against the docker daemon reachable
2
+ # through the mounted socket (see docker-compose.test.yml) — not part of the
3
+ # published library, only a way to run tests/docker on a machine with a real docker daemon.
4
+ FROM python:3.13-slim
5
+
6
+ COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /usr/local/bin/
7
+
8
+ ENV UV_PROJECT_ENVIRONMENT=/opt/venv
9
+
10
+ WORKDIR /repo
11
+ COPY . /repo
12
+
13
+ RUN uv sync --frozen
twinbox-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Lopotkov
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.
twinbox-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,323 @@
1
+ Metadata-Version: 2.5
2
+ Name: twinbox
3
+ Version: 0.1.0
4
+ Summary: Black-box parity testing of two implementations of an HTTP service, run in Docker
5
+ Project-URL: Homepage, https://gitlab.com/twinbox-group/twinbox
6
+ Project-URL: Repository, https://gitlab.com/twinbox-group/twinbox.git
7
+ Project-URL: Issues, https://gitlab.com/twinbox-group/twinbox/-/issues
8
+ Author: Mikhail Lopotkov
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: black-box,docker,http,parity,pytest,testing
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: aiohttp>=3.14.3
21
+ Requires-Dist: click>=8.2
22
+ Requires-Dist: docker>=7.2
23
+ Requires-Dist: httpx>=0.28.1
24
+ Requires-Dist: locust>=2.46
25
+ Requires-Dist: psycopg[binary]>=3.3.6
26
+ Requires-Dist: pydantic>=2
27
+ Requires-Dist: pytest
28
+ Requires-Dist: pytest-html>=4.2
29
+ Requires-Dist: pytest-xdist>=3.8.0
30
+ Requires-Dist: pyyaml
31
+ Requires-Dist: requests>=2.34.2
32
+ Requires-Dist: testcontainers>=4.15.0
33
+ Description-Content-Type: text/markdown
34
+
35
+ # twinbox
36
+
37
+ <div align="center">
38
+ <img src="docs/logo.png" alt="twinbox logo" width="240">
39
+ </div>
40
+
41
+ **Check that a rewritten HTTP service behaves exactly like the original.**
42
+
43
+ [Русская версия](README.ru.md)
44
+
45
+ twinbox is a Python library for **black-box parity testing of HTTP services**.
46
+ It is meant for teams that rewrite a service — for example, from Python to Go
47
+ or Rust — and need to show that the new implementation behaves the same way
48
+ from the outside as the old one.
49
+
50
+ You describe the expected behaviour once, as a corpus of YAML scenarios
51
+ (request → expected response). twinbox starts each implementation in a Docker
52
+ container on a fresh, isolated environment, runs the same scenarios against
53
+ it and compares the live responses with the expectations. Differences you
54
+ accept on purpose are written into the scenario together with the reason, so
55
+ they stay visible instead of being silently ignored.
56
+
57
+ > **Status: early development.** There is no release yet, and the API and
58
+ > the scenario format may still change.
59
+
60
+ ## What works today
61
+
62
+ - **Corpus check without containers** — `twinbox check` validates every
63
+ scenario (structure, tags, masks, substitutions, accepted differences,
64
+ coverage matrix) and reports each problem with the field path and an
65
+ error code.
66
+ - **Run against one implementation** — `twinbox run <name>` starts a
67
+ PostgreSQL database, a mock of external services and the implementation
68
+ itself, runs the scenarios and writes a JSON result document and,
69
+ optionally, an HTML report.
70
+ - **pytest integration** — every scenario is an ordinary pytest test, so
71
+ `-k`, `-m` and parallel runs with `pytest-xdist` work as usual.
72
+ - **Load comparison** — `twinbox load` runs locust against each
73
+ implementation in turn, with the same CPU and memory limits, and writes one
74
+ Markdown report: latency and RPS by handle, CPU and memory of the container,
75
+ CPU cost of single calls, and a verdict by handle from the project's own
76
+ criterion.
77
+
78
+ Planned after the first release: code coverage of the reference
79
+ implementation, through integration plugins.
80
+
81
+ ## How it works
82
+
83
+ For every scenario twinbox builds a clean environment:
84
+
85
+ 1. a fresh copy of a PostgreSQL database with the schema applied;
86
+ 2. a mock of the external services the implementation calls — it echoes
87
+ requests by default and can be programmed per scenario;
88
+ 3. the implementation under test, started from its Docker image.
89
+
90
+ Then it sends the scenario's requests, compares each response with the
91
+ expectation and tears the environment down. Nothing leaks from one scenario
92
+ into the next.
93
+
94
+ ## Requirements
95
+
96
+ - Python 3.13+
97
+ - [uv](https://docs.astral.sh/uv/)
98
+ - Docker; for `twinbox load` also cgroup v2 on the host and `cat` in the
99
+ implementation's image (see [docs/usage.md](docs/usage.md#environment))
100
+
101
+ ## Installation
102
+
103
+ Until the first release, install from the repository:
104
+
105
+ ```sh
106
+ uv add git+https://gitlab.com/twinbox-group/twinbox.git
107
+ ```
108
+
109
+ ## Quick start
110
+
111
+ **1. Enable twinbox in your project's `pyproject.toml`.** Every field has a
112
+ default; set only what differs:
113
+
114
+ ```toml
115
+ [tool.twinbox]
116
+ plugin = "project_plugin" # module that describes your implementations
117
+ scenarios_dir = "scenarios" # where scenario.yaml files live
118
+ ```
119
+
120
+ **2. Describe your implementations** in that module — one `Profile` per
121
+ implementation, exactly one of them marked as the reference:
122
+
123
+ ```python
124
+ from twinbox.harness.profile import Endpoint, Profile, ReadinessProbe
125
+
126
+ _API = (Endpoint(name="api", port=8080, default=True),)
127
+ _READY = ReadinessProbe(path="/health", expected_status=200)
128
+
129
+ profiles = [
130
+ Profile(
131
+ name="legacy",
132
+ is_reference=True,
133
+ default_image="shop/legacy:dev",
134
+ named_endpoints=_API,
135
+ readiness_probe=_READY,
136
+ ),
137
+ Profile(
138
+ name="rewrite",
139
+ default_image="shop/rewrite:dev",
140
+ named_endpoints=_API,
141
+ readiness_probe=_READY,
142
+ ),
143
+ ]
144
+ ```
145
+
146
+ **3. Write a scenario**, e.g. `scenarios/users/create_and_fetch/scenario.yaml`:
147
+
148
+ ```yaml
149
+ description: create a user and fetch it by the returned id
150
+ tags: [] # tags must be declared in the plugin's `labels`; none are used here
151
+ steps:
152
+ - name: create user
153
+ request:
154
+ method: POST
155
+ path: /users
156
+ json: {name: Ann}
157
+ expect:
158
+ status: 201
159
+ json_exact:
160
+ id: "{{any_uuid}}" # mask: any UUID matches
161
+ created_at: "{{any_iso8601}}" # mask: any ISO 8601 timestamp matches
162
+ name: Ann
163
+ capture:
164
+ - {name: user_id, path: id}
165
+ - name: fetch user by captured id
166
+ request:
167
+ method: GET
168
+ path: "/users/{{capture.user_id}}" # value captured in the previous step
169
+ expect:
170
+ status: 200
171
+ json_subset: {id: "{{capture.user_id}}"}
172
+ ```
173
+
174
+ **4. Check and run:**
175
+
176
+ ```sh
177
+ uv run twinbox check # validate the scenarios
178
+ uv run twinbox run legacy --image shop/legacy:dev --html reports/legacy.html
179
+ uv run twinbox run rewrite --image shop/rewrite:dev --html reports/rewrite.html
180
+ ```
181
+
182
+ Run `twinbox help` or `twinbox help <command>` for the full reference of
183
+ every command and option.
184
+
185
+ Results are written to `artifacts/results/` (JSON) and, when `--html <path>`
186
+ is passed, to the specified file path (HTML).
187
+
188
+ ## Load comparison
189
+
190
+ `twinbox load` answers the question "is the rewrite more expensive under
191
+ load?". For each implementation, one after another, it starts a fresh stand
192
+ with the CPU and memory limits from the profile, calls the project's
193
+ `load_dataset` to create data, waits for the CPU to calm down and warms the
194
+ implementation up. Then it measures: a locust ladder with the read locust
195
+ file, the same with the write locust file, and the CPU cost of single calls
196
+ to chosen handles. The ladder adds users step by step until the container
197
+ reaches its CPU limit, requests start to fail, or the steps run out; in the
198
+ last case the report says `limit not reached in <max_steps> steps`. The
199
+ peak number of database connections is a column of the report and does not
200
+ stop the ladder: the connection pool size is set inside the service, and
201
+ twinbox does not know it. A service that hits its pool shows it in this
202
+ column together with a growing p95.
203
+
204
+ **1. Give every profile CPU and memory limits:**
205
+
206
+ ```python
207
+ from twinbox.harness.profile import ResourceLimits
208
+
209
+ _LIMITS = ResourceLimits(cpu_nanocores=1_000_000_000, memory_bytes=512 * 1024 * 1024)
210
+ # in every Profile(...): resource_limits=_LIMITS
211
+ ```
212
+
213
+ **2. Point twinbox at the locust files** in `pyproject.toml`. Every other
214
+ field of `[tool.twinbox.load]` has a default:
215
+
216
+ ```toml
217
+ [tool.twinbox.load]
218
+ read_locustfile = "load/read.py"
219
+ write_locustfile = "load/write.py" # optional
220
+ step_duration = "20s" # default: 30s
221
+ max_steps = 10 # default: 20
222
+ ```
223
+
224
+ Settings of locust itself go to `[tool.locust]` or after `--`. Users, spawn
225
+ rate, run time, host and report files are set by twinbox on every step, so
226
+ `-u`, `-r`, `-t`, `-H`, `-f`, `--csv`, `--html` and `--headless` are rejected
227
+ there.
228
+
229
+ **3. Write the locust file.** `twinbox.load.binding()` returns the data that
230
+ `load_dataset` created on the current stand, `twinbox.load.endpoints()` — the
231
+ addresses of the implementation's endpoints:
232
+
233
+ ```python
234
+ from locust import HttpUser, task
235
+
236
+ from twinbox.load import binding
237
+
238
+
239
+ class ReadUser(HttpUser):
240
+ def on_start(self) -> None:
241
+ self.user_id = binding()["user_id"]
242
+
243
+ @task
244
+ def get_user(self) -> None:
245
+ self.client.get(f"/users/{self.user_id}", name="/users/[id]")
246
+ ```
247
+
248
+ **4. Add two functions to the plugin module:** `load_dataset` creates the
249
+ data, `acceptance_criterion` decides what is acceptable. The numbers come as
250
+ dataclasses from `twinbox.load`:
251
+
252
+ ```python
253
+ from collections.abc import Mapping, Sequence
254
+
255
+ import httpx
256
+
257
+ from twinbox.load import HandleVerdict, ImplementationNumbers, NetworkNumbers
258
+
259
+
260
+ def load_dataset(endpoints: Mapping[str, str], profile: str) -> Mapping[str, str]:
261
+ response = httpx.post(f"{endpoints['api']}/users", json={"name": profile}, timeout=10)
262
+ response.raise_for_status()
263
+ return {"user_id": response.json()["id"]}
264
+
265
+
266
+ def acceptance_criterion(numbers: Sequence[ImplementationNumbers]) -> Sequence[HandleVerdict]:
267
+ reference = next(item for item in numbers if item.is_reference)
268
+ if not isinstance(reference.read, NetworkNumbers):
269
+ return [] # a failed measurement already makes the exit code 2
270
+ return [
271
+ HandleVerdict(
272
+ handle=handle,
273
+ implementation=item.implementation,
274
+ accepted=own.p95_ms <= reference.read.handles[handle].p95_ms * 1.2,
275
+ note=f"p95 {own.p95_ms:.1f} ms",
276
+ )
277
+ for item in numbers
278
+ if not item.is_reference and isinstance(item.read, NetworkNumbers)
279
+ for handle, own in item.read.handles.items()
280
+ if handle in reference.read.handles
281
+ ]
282
+ ```
283
+
284
+ **5. Run:**
285
+
286
+ ```sh
287
+ uv run twinbox load # every implementation, then the verdict
288
+ uv run twinbox load rewrite # one implementation, numbers only
289
+ uv run twinbox load -- --only-summary # everything after -- goes to locust
290
+ ```
291
+
292
+ The report is written to `artifacts/load/<time>/summary.md`, next to
293
+ locust's HTML and CSV files of every step; its path is printed at the end.
294
+ Exit code `0` means every measurement ran and no verdict is rejected, `1`
295
+ means at least one verdict is rejected, `2` means a check before the start or
296
+ a measurement failed, the criterion failed, or the report was not written.
297
+ Errors are printed as `[<code>] <message>`. While the run goes, lines like
298
+ `[load] rewrite read: step 2: starting, 20 users for 30s` on stderr show which
299
+ implementation, kind and step is running now.
300
+
301
+ The CPU and memory limits apply to `twinbox run` too. The full reference —
302
+ every setting and its default, the isolated handles, the forms of the
303
+ numbers, the report and the errors — is in
304
+ [docs/usage.md](docs/usage.md#twinbox-load).
305
+
306
+ ## Examples
307
+
308
+ A runnable example lives in its own repository,
309
+ [gitlab.com/twinbox-group/example](https://gitlab.com/twinbox-group/example):
310
+ one calculator service in two implementations, Python and Rust, and nine
311
+ lessons that introduce twinbox's capabilities one at a time, starting from a
312
+ plain check.
313
+
314
+ ## Documentation
315
+
316
+ - [docs/usage.md](docs/usage.md) — settings, extension points, the scenario
317
+ format, commands and their results, the load comparison.
318
+ - [CONTRIBUTING.md](CONTRIBUTING.md) — development setup, checks, the Docker
319
+ test suite and supported platforms.
320
+
321
+ ## License
322
+
323
+ [MIT](LICENSE).