A command line tool to establish the equivalence of two alternatively built Python wheels. The approach is described and evaluated in more details in this paper.
External dependency: Installing
daleq4pyfrom PyPI installs only the Python package. The separately installed Soufflé executable is required for inference-based commands; it is not bundled in thepy3-none-anywheel.
- Python >= 3.12
- Poetry for dependency management and builds
- Soufflé available on
PATHfor inference-based commands:inference,compare-wheels, andrun-experiments. Soufflé is a separately installed system executable, not a Python package dependency.
Install dependencies (including the dev group) and build the project:
poetry install
poetry buildpoetry build produces the wheel and source distribution under dist/.
poetry run pytestEvery pull request runs the test workflow on GitHub Actions. It creates a clean Python environment from poetry.lock, installs the package and its development dependencies, then runs the complete pytest suite. Tests that require the separately installed Soufflé executable skip when it is not available on the runner.
Compares two alternatively built wheels.
poetry run daleq4py WHEEL_A WHEEL_B| Argument / option | Description |
|---|---|
WHEEL_A, WHEEL_B |
paths to the two wheels (.whl) to compare |
-v, --verbose |
enable verbose (debug) logging |
--version |
print the version and exit |
Extracts a content EDB (extensional database) from a single wheel into
<base-dir>/<wheel-name>/<session>/edb/:
content.tsv— every member of the wheel flattened into rows with columnsfile,path,key,value. Each member contributes:- Explicit keys when the member is a recognised metadata file inside
*.dist-info/: RFC 822Key: valuepairs forMETADATA/WHEEL, and one(packaged-file-path, sha256=…,size)row per line ofRECORD. - Synthetic keys (every member):
@sha256,@norm/sha256(text files only —.pyvia AST round-trip, other text via LF normalisation), and — when--srcis supplied —@src-exists,@src-exists-moved,@src/sha256,@src/norm/sha256.
- Explicit keys when the member is a recognised metadata file inside
package.tsv— a two-row package descriptor withname(the<package>-<version>token, e.g.colorlog-6.7.0) anddistinfo(the matching*.dist-infodirectory name).
The path of content.tsv is printed on success; package.tsv lives in the
same directory.
Runs Soufflé on the EDB produced by extract-edb. The script concatenates
every rules/*.souffle file (each block prefixed with a // from <path>
provenance comment) into <session>/mergedEDBAndRules.souffle, substitutes
the four <*-facts> placeholders with absolute paths to the EDB (input) and
IDB (output) TSVs, creates the sibling <session>/idb/ directory, then
invokes souffle to produce idb/content.tsv and idb/package.tsv.
poetry run inference EDB_DIR| Argument / option | Description |
|---|---|
EDB_DIR |
path to the edb/ directory produced by extract-edb |
-v, --verbose |
enable verbose (debug) logging |
Soufflé must be installed separately and its souffle binary must be on
PATH.
Renders a human-readable markdown summary of an IDB directory. For every
*.tsv file in the input folder it emits a section whose header is the
predicate name (the filename stem) and whose body is a markdown table; the
column headers are inferred from the matching .decl declaration in any
rules/*.souffle file. The summary is written to
<session>/normalisation-summary.md.
poetry run project-idb IDB_DIR| Argument / option | Description |
|---|---|
IDB_DIR |
path to the idb/ directory produced by inference |
--remove-derivation |
drop the leading id column from each table and write to normalisation-summary-reduced.md instead of normalisation-summary.md |
-v, --verbose |
enable verbose (debug) logging |
Parses a derived-fact provenance id (an encoded proof tree such as
R1[F1,R2[F2,F3]]) and prints the resulting derivation tree. The grammar
lives in src/daleq4py/proof.lark and is the Lark equivalent of the
antlr4 Proof.g4 grammar in the daleq Java project.
poetry run parse-provenance 'R1[F1,R2[F2,F3,F4]]'Output:
R1
├── F1
└── R2
├── F2
├── F3
└── F4
| Argument / option | Description |
|---|---|
EXPRESSION |
the derivation id to parse |
-v, --verbose |
enable verbose (debug) logging |
poetry run extract-edb WHEEL [--src ZIP]| Argument / option | Description |
|---|---|
WHEEL |
path to the wheel (.whl) to extract content from |
--src ZIP |
optional "trusted" wheel/zip whose members define the baseline for the @src-* synthetic keys; when omitted, those keys are not emitted |
--base-dir DIR |
root directory for the EDB tree; a relative path is resolved against the project root (default: .souffle) |
-v, --verbose |
enable verbose (debug) logging |
The test suite writes its EDB files to .souffle-test/ instead.
Normalises Python source files for binary-equivalence analysis by
round-tripping them through the stdlib AST (ast.parse + ast.unparse).
This:
- removes
#comments (the AST does not preserve them); - standardises layout to a canonical, deterministic form;
- writes
\nline endings unconditionally.
Docstrings are kept (they are string-literal expressions, not comments). Files that fail to parse are reported and skipped; the exit status is non-zero if any file failed.
poetry run normalise-pysrc PATH [PATH ...] (-o DIR | -i)| Argument / option | Description |
|---|---|
PATH |
one or more Python source files or directories (directories are recursed for *.py) |
-o, --output DIR |
write normalised files under DIR, mirroring the input tree |
-i, --in-place |
overwrite source files in place |
-v, --verbose |
enable verbose (debug) logging |
Exactly one of --output or --in-place is required.
Compares two wheels by partitioning their contents into three groups and applying a different equality test to each:
*.dist-infometadata — runsextract-edb,inferenceandproject-idb(twice, with and without--remove-derivation) on both wheels, then compares the reduced markdown summaries character by character.*.pysource files — normalises each shared.pymember with thenormalise-pysrcAST round-trip and compares the results character by character.- All other files — byte-by-byte comparison.
Members present in only one wheel count as a difference. The three per-group
booleans are combined with and to produce the overall result. Exit code:
0 if the wheels are equal, 1 if they differ, 2 on error.
poetry run compare-wheels WHEEL_A WHEEL_B [--src ZIP]| Argument / option | Description |
|---|---|
WHEEL_A, WHEEL_B |
paths to the two wheels (.whl) to compare |
--src ZIP |
optional "trusted" wheel/zip whose .py members define the baseline set legitimately expected in a built wheel; any .py member of WHEEL_A or WHEEL_B not present in this baseline is flagged as suspicious. May be the same path as WHEEL_A, WHEEL_B, or any other zip. Does not affect the equality booleans — it's informational |
--base-dir DIR |
root directory for EDB / IDB / projection artefacts; a relative path is resolved against the project root (default: .souffle) |
-v, --verbose |
enable verbose (debug) logging |
The programmatic entry point
daleq4py.compare_wheels.compare_wheels(wheel_a, wheel_b) returns a
(bool, dict) pair. The dict contains, for each of the two wheels:
wheels— input wheel pathedb_dirs— extracted EDB directoryidb_dirs— inferred IDB directorysummaries,summaries_reduced— full and reduced markdown projectionsmerged_rules— path tomergedEDBAndRules.souffle
plus the per-group booleans metadata_equal, py_files_equal,
other_files_equal, and the lists of differing / one-sided member names for
the .py and other-file groups. When src is supplied, the dict also
carries src (the baseline path) and the two suspicious-member lists
py_files_unexpected_in_a / py_files_unexpected_in_b.
Produces a markdown diff report between two wheels. The report lists, with
SHA-256 digests, the files present in only one wheel, the files that are
byte-identical in both, and the files that are present in both but differ —
each with a unified textual diff (for files that decode as text) or a
*Binary files differ.* note.
poetry run wheel-diff WHEEL_1 WHEEL_2 [-o FILE]| Argument / option | Description |
|---|---|
WHEEL_1, WHEEL_2 |
paths to the two wheels (.whl) to diff |
-o, --output FILE |
write the report to FILE instead of stdout |
-v, --verbose |
enable verbose (debug) logging |
Adapted from pyeqpkg/diff-script/wheel-diff.py.
Sweeps sample-data/ and runs compare-wheels for every pair of builds of
the same (package, version). The expected sample layout is
sample-data/<package>/<version>/<build>/<wheel>.whl. One markdown report
per pair is written to:
sample-data-results/<package>/<version>/<build_a>-vs-<build_b>.md # comparison report
sample-data-results/<package>/<version>/<build_a>-vs-<build_b>-diff.md # wheel-diff report
sample-data-results/<package>/<version>/<build_a>-vs-<build_b>-idb-diff.md # unified diff of full IDB projections
sample-data-results/<package>/<version>/<build_a>-vs-<build_b>-idb-reduced-diff.md # unified diff of reduced IDB projections
The comparison report links to all three diff reports in its header. The two IDB-diff reports are only produced when the comparison pipeline ran successfully (they read the projection files it writes).
A top-level sample-data-results/summary.md is also written: it carries the
counts of equal / different / errored pairs and a details table (package,
build combination, overall / metadata / python source / other results) with
each row linking to its per-pair report.
Pipeline artefacts (EDB, IDB, projections, mergedEDBAndRules.souffle) for
each pair land under sample-data-results/.artefacts/<package>/<version>/<build_a>-vs-<build_b>/,
and the markdown reports link to them with relative paths. Each report lists:
- the overall result and the per-group results (metadata /
.py/ other); - a table of artefact paths for both builds;
- the names of any members that differ or appear in only one build.
For each pair the sweep also picks a "trusted" baseline (src) per the
following policy and passes it to compare-wheels: if macaron is one of
the pair's builds, the macaron wheel is src; otherwise the ossr wheel is
src. Each per-pair report ends with a Trust check section listing any
.py members in either build that are absent from the baseline. The trust
check is informational and does not affect the per-group / overall booleans.
A pair that raises during comparison is recorded as ERROR in its report;
the sweep continues with the next pair. Exit code: 0 if no pair errored,
1 otherwise.
poetry run run-experiments| Argument / option | Description |
|---|---|
--sample-dir DIR |
sample-data root (default: sample-data) |
--results-dir DIR |
output root (default: sample-data-results) |
-v, --verbose |
enable verbose (debug) logging |
Use --help with any command for the full list of options.