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.
- twinbox-0.1.0/.dockerignore +9 -0
- twinbox-0.1.0/.gitignore +10 -0
- twinbox-0.1.0/.gitlab-ci.yml +120 -0
- twinbox-0.1.0/CONTRIBUTING.md +119 -0
- twinbox-0.1.0/Dockerfile.test +13 -0
- twinbox-0.1.0/LICENSE +21 -0
- twinbox-0.1.0/PKG-INFO +323 -0
- twinbox-0.1.0/README.md +289 -0
- twinbox-0.1.0/README.ru.md +288 -0
- twinbox-0.1.0/Taskfile.yml +45 -0
- twinbox-0.1.0/docker-compose.test.yml +14 -0
- twinbox-0.1.0/docs/logo.png +0 -0
- twinbox-0.1.0/docs/usage.md +560 -0
- twinbox-0.1.0/pyproject.toml +141 -0
- twinbox-0.1.0/src/twinbox/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/comparison/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/comparison/expectation.py +41 -0
- twinbox-0.1.0/src/twinbox/comparison/masks.py +419 -0
- twinbox-0.1.0/src/twinbox/comparison/response.py +351 -0
- twinbox-0.1.0/src/twinbox/coverage/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/coverage/matrix.py +212 -0
- twinbox-0.1.0/src/twinbox/errors.py +29 -0
- twinbox-0.1.0/src/twinbox/execution/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/execution/capture.py +168 -0
- twinbox-0.1.0/src/twinbox/execution/mock_calls.py +245 -0
- twinbox-0.1.0/src/twinbox/execution/placeholders.py +493 -0
- twinbox-0.1.0/src/twinbox/execution/request.py +284 -0
- twinbox-0.1.0/src/twinbox/execution/run.py +620 -0
- twinbox-0.1.0/src/twinbox/harness/__init__.py +27 -0
- twinbox-0.1.0/src/twinbox/harness/database.py +490 -0
- twinbox-0.1.0/src/twinbox/harness/docker_host.py +16 -0
- twinbox-0.1.0/src/twinbox/harness/env_pairs.py +24 -0
- twinbox-0.1.0/src/twinbox/harness/mock.py +328 -0
- twinbox-0.1.0/src/twinbox/harness/network.py +73 -0
- twinbox-0.1.0/src/twinbox/harness/profile.py +231 -0
- twinbox-0.1.0/src/twinbox/harness/sut.py +377 -0
- twinbox-0.1.0/src/twinbox/launch/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/launch/cli.py +607 -0
- twinbox-0.1.0/src/twinbox/launch/cli_help.py +169 -0
- twinbox-0.1.0/src/twinbox/launch/corpus.py +67 -0
- twinbox-0.1.0/src/twinbox/launch/extensions.py +335 -0
- twinbox-0.1.0/src/twinbox/launch/plugin.py +865 -0
- twinbox-0.1.0/src/twinbox/launch/registry.py +65 -0
- twinbox-0.1.0/src/twinbox/launch/settings.py +150 -0
- twinbox-0.1.0/src/twinbox/launch/stand.py +768 -0
- twinbox-0.1.0/src/twinbox/launch/worker_results.py +237 -0
- twinbox-0.1.0/src/twinbox/load/__init__.py +35 -0
- twinbox-0.1.0/src/twinbox/load/acceptance.py +126 -0
- twinbox-0.1.0/src/twinbox/load/context.py +66 -0
- twinbox-0.1.0/src/twinbox/load/cpu_benchmark.py +282 -0
- twinbox-0.1.0/src/twinbox/load/errors.py +9 -0
- twinbox-0.1.0/src/twinbox/load/metrics.py +213 -0
- twinbox-0.1.0/src/twinbox/load/network_measurement.py +432 -0
- twinbox-0.1.0/src/twinbox/load/numbers.py +100 -0
- twinbox-0.1.0/src/twinbox/load/output.py +123 -0
- twinbox-0.1.0/src/twinbox/load/population.py +88 -0
- twinbox-0.1.0/src/twinbox/load/run.py +735 -0
- twinbox-0.1.0/src/twinbox/load/settings.py +261 -0
- twinbox-0.1.0/src/twinbox/load/stand.py +263 -0
- twinbox-0.1.0/src/twinbox/outcome/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/outcome/allowance.py +215 -0
- twinbox-0.1.0/src/twinbox/outcome/verdict.py +308 -0
- twinbox-0.1.0/src/twinbox/py.typed +0 -0
- twinbox-0.1.0/src/twinbox/reports/__init__.py +5 -0
- twinbox-0.1.0/src/twinbox/reports/artifacts.py +74 -0
- twinbox-0.1.0/src/twinbox/reports/step_result.py +713 -0
- twinbox-0.1.0/src/twinbox/reports/trace.py +121 -0
- twinbox-0.1.0/src/twinbox/scenario/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox/scenario/fields.py +344 -0
- twinbox-0.1.0/src/twinbox/scenario/layout.py +77 -0
- twinbox-0.1.0/src/twinbox/scenario/tags.py +133 -0
- twinbox-0.1.0/src/twinbox/scenario/validation.py +956 -0
- twinbox-0.1.0/src/twinbox/yamlio.py +150 -0
- twinbox-0.1.0/src/twinbox_mock/Dockerfile +14 -0
- twinbox-0.1.0/src/twinbox_mock/__init__.py +1 -0
- twinbox-0.1.0/src/twinbox_mock/__main__.py +35 -0
- twinbox-0.1.0/src/twinbox_mock/app.py +28 -0
- twinbox-0.1.0/src/twinbox_mock/control_api.py +70 -0
- twinbox-0.1.0/src/twinbox_mock/journal.py +64 -0
- twinbox-0.1.0/src/twinbox_mock/matching.py +39 -0
- twinbox-0.1.0/src/twinbox_mock/program.py +177 -0
- twinbox-0.1.0/src/twinbox_mock/py.typed +0 -0
- twinbox-0.1.0/src/twinbox_mock/response.py +119 -0
- twinbox-0.1.0/src/twinbox_mock/state.py +41 -0
- twinbox-0.1.0/tests/conftest.py +3 -0
- twinbox-0.1.0/tests/docker/conftest.py +79 -0
- twinbox-0.1.0/tests/docker/test_database_lifecycle.py +153 -0
- twinbox-0.1.0/tests/docker/test_ensure_image.py +34 -0
- twinbox-0.1.0/tests/docker/test_load_metrics_resource_bounds.py +84 -0
- twinbox-0.1.0/tests/docker/test_load_run_parity.py +181 -0
- twinbox-0.1.0/tests/docker/test_mock_control_api.py +89 -0
- twinbox-0.1.0/tests/docker/test_raise_mock_reachability.py +59 -0
- twinbox-0.1.0/tests/docker/test_run_parity.py +246 -0
- twinbox-0.1.0/tests/docker/test_sut_lifecycle.py +122 -0
- twinbox-0.1.0/tests/fixtures/basic/coverage.md +22 -0
- twinbox-0.1.0/tests/fixtures/basic/project_plugin.py +106 -0
- twinbox-0.1.0/tests/fixtures/basic/pyproject.toml +4 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/health/ping/scenario.yaml +18 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/payments/refund_check/mocks.yaml +11 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/payments/refund_check/scenario.yaml +43 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/users/create_and_fetch/scenario.yaml +41 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/webhooks/relay/config/app.conf +1 -0
- twinbox-0.1.0/tests/fixtures/basic/scenarios/webhooks/relay/scenario.yaml +21 -0
- twinbox-0.1.0/tests/fixtures/broken/matrix.md +13 -0
- twinbox-0.1.0/tests/fixtures/broken/project_plugin.py +106 -0
- twinbox-0.1.0/tests/fixtures/broken/pyproject.toml +4 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/ambiguous_body_mode/scenario.yaml +10 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/config_dir_outside_scenario/scenario.yaml +8 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/conflicting_allowance_forms/scenario.yaml +13 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/divergence_on_reference/scenario.yaml +9 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/duplicate_capture_name/scenario.yaml +15 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/env_var_missing/scenario.yaml +10 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/incompatible_tags/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/invalid_field_value/scenario.yaml +9 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/label_incompatible_with_allowance/scenario.yaml +9 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/malformed_yaml/scenario.yaml +3 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/mask_forbidden_here/scenario.yaml +9 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/missing_required_field/scenario.yaml +5 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_invalid/mocks.yaml +3 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_invalid/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_malformed/mocks.yaml +1 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/mocks_program_malformed/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/multiple_errors/scenario.yaml +7 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/not_allowed_here/scenario.yaml +8 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/reason_missing/scenario.yaml +10 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/setup_step_kind_ambiguous/scenario.yaml +10 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/sql_setup_not_enabled/scenario.yaml +8 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_capture_reference/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_field/scenario.yaml +7 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_implementation/scenario.yaml +10 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_placeholder/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_tag/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/broken/scenarios/unknown_target/scenario.yaml +6 -0
- twinbox-0.1.0/tests/fixtures/parity/load/read.py +28 -0
- twinbox-0.1.0/tests/fixtures/parity/load/write.py +19 -0
- twinbox-0.1.0/tests/fixtures/parity/migration/Dockerfile +10 -0
- twinbox-0.1.0/tests/fixtures/parity/migration/schema.sql +7 -0
- twinbox-0.1.0/tests/fixtures/parity/plugin.py +201 -0
- twinbox-0.1.0/tests/fixtures/parity/pyproject.toml +23 -0
- twinbox-0.1.0/tests/fixtures/parity/reference/Dockerfile +13 -0
- twinbox-0.1.0/tests/fixtures/parity/reference/app.py +119 -0
- twinbox-0.1.0/tests/fixtures/parity/replacement/Dockerfile +22 -0
- twinbox-0.1.0/tests/fixtures/parity/replacement/go.mod +5 -0
- twinbox-0.1.0/tests/fixtures/parity/replacement/main.go +210 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/health/probe_with_diagnostics/scenario.yaml +15 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/create_and_read/scenario.yaml +27 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/read_extra_field/scenario.yaml +35 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/setup_via_api/scenario.yaml +23 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/items/setup_via_sql/scenario.yaml +15 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/notify/relay_through_mock/mocks.yaml +7 -0
- twinbox-0.1.0/tests/fixtures/parity/scenario_tests/notify/relay_through_mock/scenario.yaml +23 -0
- twinbox-0.1.0/tests/integration/test_fixture_projects.py +316 -0
- twinbox-0.1.0/tests/unit/conftest.py +48 -0
- twinbox-0.1.0/tests/unit/fixtures/load/no_aggregated_stats.csv +2 -0
- twinbox-0.1.0/tests/unit/fixtures/load/step1_stats.csv +3 -0
- twinbox-0.1.0/tests/unit/fixtures/load/step2_failing_stats.csv +3 -0
- twinbox-0.1.0/tests/unit/fixtures/load/step2_stats.csv +3 -0
- twinbox-0.1.0/tests/unit/test_allowance.py +240 -0
- twinbox-0.1.0/tests/unit/test_app.py +53 -0
- twinbox-0.1.0/tests/unit/test_artifacts.py +75 -0
- twinbox-0.1.0/tests/unit/test_capture.py +164 -0
- twinbox-0.1.0/tests/unit/test_cli.py +980 -0
- twinbox-0.1.0/tests/unit/test_control_api.py +206 -0
- twinbox-0.1.0/tests/unit/test_corpus.py +97 -0
- twinbox-0.1.0/tests/unit/test_docker_host.py +23 -0
- twinbox-0.1.0/tests/unit/test_english_only.py +71 -0
- twinbox-0.1.0/tests/unit/test_errors.py +50 -0
- twinbox-0.1.0/tests/unit/test_extensions.py +397 -0
- twinbox-0.1.0/tests/unit/test_fields.py +541 -0
- twinbox-0.1.0/tests/unit/test_harness_database.py +676 -0
- twinbox-0.1.0/tests/unit/test_harness_env_pairs.py +32 -0
- twinbox-0.1.0/tests/unit/test_harness_mock.py +440 -0
- twinbox-0.1.0/tests/unit/test_harness_network.py +90 -0
- twinbox-0.1.0/tests/unit/test_harness_sut.py +424 -0
- twinbox-0.1.0/tests/unit/test_journal.py +105 -0
- twinbox-0.1.0/tests/unit/test_layout.py +96 -0
- twinbox-0.1.0/tests/unit/test_load_acceptance.py +200 -0
- twinbox-0.1.0/tests/unit/test_load_context.py +60 -0
- twinbox-0.1.0/tests/unit/test_load_cpu_benchmark.py +365 -0
- twinbox-0.1.0/tests/unit/test_load_import.py +29 -0
- twinbox-0.1.0/tests/unit/test_load_metrics.py +329 -0
- twinbox-0.1.0/tests/unit/test_load_network_measurement.py +533 -0
- twinbox-0.1.0/tests/unit/test_load_numbers.py +132 -0
- twinbox-0.1.0/tests/unit/test_load_output.py +206 -0
- twinbox-0.1.0/tests/unit/test_load_population.py +101 -0
- twinbox-0.1.0/tests/unit/test_load_run.py +1324 -0
- twinbox-0.1.0/tests/unit/test_load_settings.py +221 -0
- twinbox-0.1.0/tests/unit/test_load_stand.py +678 -0
- twinbox-0.1.0/tests/unit/test_masks.py +320 -0
- twinbox-0.1.0/tests/unit/test_matching.py +60 -0
- twinbox-0.1.0/tests/unit/test_matrix.py +231 -0
- twinbox-0.1.0/tests/unit/test_mock_calls.py +372 -0
- twinbox-0.1.0/tests/unit/test_mock_main.py +58 -0
- twinbox-0.1.0/tests/unit/test_mock_response.py +268 -0
- twinbox-0.1.0/tests/unit/test_placeholders.py +594 -0
- twinbox-0.1.0/tests/unit/test_plugin.py +749 -0
- twinbox-0.1.0/tests/unit/test_plugin_xdist.py +658 -0
- twinbox-0.1.0/tests/unit/test_profile.py +252 -0
- twinbox-0.1.0/tests/unit/test_program.py +261 -0
- twinbox-0.1.0/tests/unit/test_registry.py +76 -0
- twinbox-0.1.0/tests/unit/test_request.py +378 -0
- twinbox-0.1.0/tests/unit/test_response.py +779 -0
- twinbox-0.1.0/tests/unit/test_run.py +882 -0
- twinbox-0.1.0/tests/unit/test_settings.py +171 -0
- twinbox-0.1.0/tests/unit/test_stand.py +1020 -0
- twinbox-0.1.0/tests/unit/test_state.py +50 -0
- twinbox-0.1.0/tests/unit/test_step_result.py +610 -0
- twinbox-0.1.0/tests/unit/test_tags.py +115 -0
- twinbox-0.1.0/tests/unit/test_trace.py +162 -0
- twinbox-0.1.0/tests/unit/test_validation.py +807 -0
- twinbox-0.1.0/tests/unit/test_verdict.py +320 -0
- twinbox-0.1.0/tests/unit/test_worker_results.py +306 -0
- twinbox-0.1.0/tests/unit/test_yamlio.py +86 -0
- twinbox-0.1.0/uv.lock +2194 -0
twinbox-0.1.0/.gitignore
ADDED
|
@@ -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).
|