Contributing to PySuricata
Thank you for considering contributing to PySuricata! This guide will help you get started.
Development Setup
Prerequisites
- Python 3.10+
uvpackage manager (recommended) orpip- Git
Clone Repository
Install Dependencies
Running Tests
# The suite. `not benchmark` is not optional -- a bare `pytest` pulls the
# benchmark modules in, and they take minutes.
uv run pytest -m "not benchmark"
# With coverage
uv run pytest -m "not benchmark" --cov=pysuricata --cov-report=html
# One file
uv run pytest tests/test_numeric.py
The Gates
Five things guard behaviour that is easy to break without noticing. Run them before opening a pull request; CI runs them all.
The accuracy oracle
Asserts that chunked results equal unchunked results. It is the invariant the whole design rests on and the one most likely to break — a change that makes this fail is wrong even if it is faster.
The docs checker
uv run python -m benchmarks.check_docs --strict # exit 1 on any ERROR
uv run python -m benchmarks.check_docs --quiet-info # the readable report
uv run python -m benchmarks.check_docs --json out.json
Runs every code fence in docs/ and the README against the live API, checks
that every documented symbol is exported, and reports pages on disk that the nav
never renders. If you add a fence, it will be executed — give it its imports.
The three ratchets
| ratchet | guards |
|---|---|
tests/test_report_layout.py |
report bytes, and elements per card |
tests/test_colour_tokens.py |
untokenised colours |
tests/test_processed_bytes_placement.py |
Processed bytes staying out of a stat row |
Each fails in both directions. Growth is a regression; shrinking asks you to lower the baseline, so a win cannot be quietly respent.
Data invariance
Asserts the facts have not moved while the presentation has. This is what made a seventeen-commit rewrite of every template reviewable.
Browser layout checks
Deliberately not in dev: Chromium is ~300 MB and only 31 cases need it, so
they skip themselves when it is absent — which means you can break a layout
criterion and still see green locally.
uv run python scripts/contact_sheet.py produces six review captures. It is
never a gate.
Benchmarks
python -m benchmarks.hotspots # where profile() spends its time
python -m benchmarks.kernels # per-kernel timings + memory roofline
python -m benchmarks.end_to_end --markdown results.md # vs ydata/sweetviz/skimpy
python -m benchmarks.versions # this version against previous ones
Two rules, both learned the hard way:
cProfilecharges per Python call, so it over-weights kernels that make many small ones. It ranked the reservoir sampler at ~30% of self time when a 5x-faster replacement moved wall clock by 4%. Confirm any ranking against wall clock with the profiler off.- A ratio is only quotable when both sides were measured in the same
round-robin, on the same machine, in the same run, with nothing else
running. The last clause is why both harnesses read the load average and
refuse above one process per core without
--force: a run once showed a 10.5% regression that was the coverage suite running in parallel.
The Native Crate
The Rust kernels in native/ are an optional accelerator. The pure-Python
path is the reference implementation and must never be deleted to "simplify" —
the two must agree within documented tolerance.
Pre-commit
pre-commit is a dev dependency and the ruff revision in
.pre-commit-config.yaml is kept in sync with pyproject.toml.
Code Style
PySuricata uses Ruff for linting and formatting.
Line length 88, target Python 3.10, from __future__ import annotations at the
top of every module.
Style Guidelines
- Follow PEP 8
- Line length: 88 characters (Black-style)
- Use type hints for function signatures
- Docstrings: Google style
Example:
def compute_mean(values: np.ndarray) -> float:
"""Compute arithmetic mean of values.
Args:
values: Array of numeric values
Returns:
Mean value
Raises:
ValueError: If array is empty
"""
if len(values) == 0:
raise ValueError("Cannot compute mean of empty array")
return float(np.mean(values))
Documentation
Build Documentation Locally
# Install docs dependencies
uv sync --dev
# The example report is generated, not committed. Every docs workflow runs
# this before building; run it once locally so the iframe on the home page
# has something to show.
uv run python scripts/regenerate_example_report.py
# Build docs
uv run mkdocs serve
# Open http://localhost:8000 in browser
docs/assets/titanic_report.html is in .gitignore. It used to be committed,
and it drifted from 0.0.17 onward until it was 1,180,196 bytes against 600,028
of real output -- because every rendering change either produced a megabyte diff
or produced none, and nobody wanted the diff. mkdocs build --strict does not
need the file to be present.
Documentation Style
- Use clear, concise language
- Include code examples
- Add mathematical formulas for algorithms
- Link to related pages
- Update relevant sections when changing code
Pull Request Process
1. Create Feature Branch
Branch naming:
- feature/ - New features
- fix/ - Bug fixes
- docs/ - Documentation only
- refactor/ - Code refactoring
- test/ - Test improvements
2. Make Changes
- Write tests for new functionality
- Update documentation
- Follow code style guidelines
- Keep commits atomic and well-described
3. Run Checks
uv run ruff format . && uv run ruff check .
uv run pytest -m "not benchmark"
uv run pytest benchmarks/accuracy.py -v
uv run python -m benchmarks.check_docs --strict
uv run python scripts/regenerate_example_report.py && uv run mkdocs build --strict
One more, if you are about to open the pull request from a branch you created
with git checkout -B: that command aborts against uncommitted changes and
says so in one line, which is easy to miss — a commit made afterwards lands on
the old base and looks fine.
4. Commit Changes
Commit message format:
- feat: - New feature
- fix: - Bug fix
- docs: - Documentation
- refactor: - Code refactoring
- test: - Test updates
- chore: - Build/tooling changes
5. Push and Create PR
Then create Pull Request on GitHub with: - Clear description of changes - Link to related issues - Screenshots for UI changes - Checklist of completed items
Testing Guidelines
Unit Tests
Test individual functions/classes in isolation.
def test_welford_mean():
"""Test Welford mean computation"""
from pysuricata.accumulators.algorithms import StreamingMoments
moments = StreamingMoments()
values = [1.0, 2.0, 3.0, 4.0, 5.0]
for v in values:
moments.update(np.array([v]))
result = moments.finalize()
assert abs(result["mean"] - 3.0) < 1e-10
Integration Tests
Test components working together.
from pysuricata import profile
def test_full_profile():
"""Test end-to-end profiling"""
df = pd.DataFrame({"x": [1, 2, 3], "y": ["a", "b", "c"]})
report = profile(df)
assert report.html is not None
assert len(report.stats["columns"]) == 2
What to Assert
Two properties are worth reaching for beyond "the number is right", because they are the ones this codebase's bugs hide behind.
Order independence and mergeability. An accumulator must produce the same
answer however the data is split, and merge must be exact:
import numpy as np
from pysuricata.accumulators import NumericAccumulator
values = np.arange(1_000, dtype=float)
whole = NumericAccumulator("x")
whole.update(values)
left, right = NumericAccumulator("x"), NumericAccumulator("x")
left.update(values[:400])
right.update(values[400:])
left.merge(right)
assert left.finalize().mean == whole.finalize().mean
That a control resolves. A class renamed on one side of the
render/JavaScript boundary produces no error and no console warning, just a
button that goes quiet — + add a note did nothing for eleven versions this
way, and 1,735 tests did not notice because not one asserted that a selector
resolves. tests/test_js_selectors_match_markup.py now pairs every
closest() selector against the markup that must match it; extend it when you
add a control.
One trap when asserting over rendered output: the report inlines its own CSS
and JS, so searching the whole document for a class name finds it in the very
source that references it. Strip <script> and <style> first, or require a
class=" attribute. "dt-svg" in html was True for a class no element
carried.
Architecture Overview
pysuricata/
├── api.py # profile() / summarize() -- the public surface
├── cli.py # the `pysuricata` command: profile, summarize, check
├── check.py # baselines, thresholds and findings behind `check`
├── comparison.py # compare() and the delta dataclasses
├── sources.py # Parquet / Arrow IPC / DuckDB batch readers
├── config.py # EngineConfig -- internal, built from ComputeOptions
├── report.py # build_report(): the orchestration entry point
├── checkpoint.py # periodic state to disk for long runs
├── progress.py # progress= reporting, always to stderr
├── io/ # shared reader plumbing
├── accumulators/ # the statistical core
│ ├── algorithms.py # StreamingMoments, ExtremeTracker, monotonicity
│ ├── sketches.py # KMV, MisraGries, ReservoirSampler, RowKMV
│ ├── numeric.py categorical.py datetime.py boolean.py
│ └── factory.py # accumulator selection and per-column seeding
├── compute/
│ ├── orchestration/ # engine.py -- the chunk loop and adapter dispatch
│ ├── adapters/ # pandas.py, polars.py -- frame-shaped I/O
│ ├── processing/ # chunking.py, inference.py
│ ├── analysis/ # correlation.py
│ ├── consume.py # pandas chunk -> accumulator wiring
│ └── consume_polars.py
├── render/ # HTML generation; html.py drives the template
├── templates/ # report_template.html -- placeholders, one regex pass
└── static/ # CSS, JS, images, all inlined into the report
native/ # optional Rust kernels (PyO3 + maturin)
benchmarks/ # accuracy oracle, performance harness, docs checker
Data flows one way: an adapter yields chunks, consume_chunk_* converts each
column to an array, the matching accumulator's update() folds it in,
finalize() produces a summary dataclass, and render/ turns summaries into
HTML. Accumulators never see the frame, only arrays.
Two invariants to keep:
- Accumulators are mergeable and order-independent wherever the statistic allows it. Chunked must equal unchunked.
- Approximate values are labelled approximate. Sketches carry error bounds; surface them rather than printing an estimate as an exact integer.
And one rule: never touch the global RNG. Seeds belong to the accumulator instance.
Adding New Features
Add New Statistic to Numeric Analysis
Five steps, and the fifth is the one people forget.
1. Carry the state on the accumulator (pysuricata/accumulators/numeric.py).
It must be foldable one array at a time and combinable in merge, or the
chunked-equals-unchunked invariant breaks:
NumericAccumulator.__init__ add the state, seeded from the instance's own RNG
NumericAccumulator.update fold one array of values into it
NumericAccumulator.merge combine this state with another accumulator's
NumericAccumulator.finalize emit it on the summary dataclass
2. Add the field to the summary dataclass, with a default, so an accumulator that never saw the column still finalizes.
3. Render it in the matching card under pysuricata/render/.
4. Test the arithmetic and the invariant — that splitting the input in two and merging gives the same answer.
5. Publish it, or withhold it deliberately. Every computed statistic must be
either in the summarize() payload or listed in
pysuricata.report.SUMMARY_FIELDS_WITHHELD with a reason. A test walks that
list against the summary dataclasses and fails if a statistic is neither. That
is what stops the JSON drifting behind the HTML — it has happened twice, with
correlations and with numeric top values, and both times it was only findable by
reading the renderer.
If the new value is an estimate, it carries its error bound. Adding an
approximate number without one is the thing the project is most careful about.
See the summarize() schema.
- Update documentation (
docs/stats/numeric.md):
Release Process
Documented once, in Versioning — the five-stage pipeline, what
scripts/check_version.py enforces about a version bump, and the gates for
1.0.0.
A pull request does not have to bump the version. If it does, the bump must
be a legal step — one component raised, the ones below it reset, nothing
skipped, no downgrade — with a matching CHANGELOG.md section.
Community Guidelines
- Be respectful and inclusive
- Help others learn and grow
- Focus on constructive feedback
- Assume good intentions
Getting Help
License
By contributing, you agree that your contributions will be licensed under the MIT License.