Contributing
Contributing to ciplink¶
This document aims to provide a brief guide on how to contribute to ciplink.
Who can contribute?¶
Anyone! Contributions from any user are welcome. Contributions aren't limited to changing code. Filing bug reports, asking questions, adding examples or documentation are all ways to contribute. New users may find it helpful to start with improving documentation, type hinting, or tests.
Asking a question¶
Questions can be submitted as either an issue or a discussion post. A general question not directly related to the code or one that may be beneficial to other users would be most appropriate in the discussions area. One that is about a specific feature or could turn into a feature request or bug report would be more appropriate as an issue. If submitting a question as an issue, please use the question template.
Submitting an Issue¶
No code is perfect, ciplink is no different and user submitted issues aid in improving the quality of this library.
Before submitting an issue, check to see if someone has already submitted one before so we can avoid duplicate issues.
Bug Reports¶
To submit a bug report, please create an issue using the Bug Report template. Please include as much information as
possible relating to the bug. The more detailed the bug report, the easier and faster it will be to resolve.
Some details to include:
- The version of ciplink (easily found with the pip show ciplink command)
- The language binding used (Python, C, C++, C# or Rust)
- Model/Firmware/etc if the issue is related to a specific device or firmware version
- Logs (see the documentation to configure)
- A helper method is provided to simplify logging configs, including logging to a file
- Using the LOG_VERBOSE level is the most helpful
- Sample code that will reproduce the bug
Feature Requests¶
For feature requests or enhancements, please create an issue using the Feature Request template. New features could be
things like:
- A missing feature from a similar library
- e.g. Library X has a feature Y, would it be possible to add Y functionality to ciplink?
- Change or modification to the API
- If it's a breaking change be sure to include why the new functionality is better than the current
- Enhancing a current feature
- Removing an old/broken/unsupported feature
Submitting Changes¶
Submitting code or documentation changes is another way to contribute. All contributions should be made in the form of
a pull request. You should fork this repository and clone it to your machine. Pull requests target the main
branch; releases are built from version tags.
Some requirements for code changes to be accepted include:
- the bytes on the wire stay identical to the reference driver's (see Fixtures and the reference driver); a change to what is sent comes with the fixtures or differential scenarios that show it
- Python code is pythonic and follows PEP8, PEP20, and other common conventions; Rust code passes
cargo fmtandcargo clippy -- -D warnings - public methods have docstrings (Python) or
///docs (Rust), which are included in the documentation - comments and docstrings explain why and how the code works, not merely what it is doing
- type hinting is used as much as possible, all public Python methods have hints
- new functionality has tests, run against the simulator (
ciplink-sim), never against a real controller - every suite below passes
- the Python package needs only the standard library at run time; the Rust core keeps its dependencies few and well known
- avoid breaking changes, unless adequately justified
- do not update the library version
Some suggested contributions include: - tests - new tests are always welcome, particularly simulator scenarios or fixtures for behaviour not yet covered - examples - example scripts showing how to use this library or any of it's features - you may include just the example script if you're not comfortable with also updating the docs to include it
Development¶
Layout¶
| Path | What |
|---|---|
crates/ciplink |
the Rust core: codecs, CIP, Logix and SLC drivers, the tag cache file |
crates/ciplink-py |
the PyO3 extension module ciplink._core, its tests (tests/) and benchmarks (bench/) |
python/ciplink |
the Python half of the package: the pycomm3 API, data types, packets and tags |
crates/ciplink-ffi |
the C ABI and include/ciplink.h (generated by cbindgen) |
bindings/cpp, bindings/dotnet |
the C++ and C# wrappers and their tests, see bindings/README.md |
crates/ciplink-sim |
ciplink-sim, the simulated controller every test runs against |
tests/offline, tests/online |
the public-API Python tests, see tests/README.md |
tools/reference |
the pure-Python reference driver (ciplink_ref) and its own tests |
tools/fixtures, tools/diff, tools/sim |
fixture generators, differential scenarios, simulator configurations and checks |
The root pyproject.toml builds the package with maturin: the Python sources come from
python/, the extension module from crates/ciplink-py. The sources are in python/, not at the repo root, so a
checkout never shadows the installed package when the tests run from the root.
Toolchain¶
- Rust 1.85 or newer (edition 2024) with
rustfmtandclippy. On Windows, either the MSVC toolchain, or the GNU toolchain with a MinGW-w64 distribution (dlltool,asandg++) onPATH. - uv for the Python environments (and
uvx maturin), Python 3.9 or newer (the commands below use 3.12). - For the bindings: a C++17 compiler (
g++by default, setCXXfor another, or CMake 3.16+ forBUILD=cmake), the .NET SDK 9.0.200 or newer (the solution is a.slnxfile; the library targets .NET 8), and optionallycbindgento regenerateciplink.h.
Building¶
uvx maturin build --release # the wheel, in target/wheels/
uvx maturin develop --release # or build and install it into the active virtualenv
cargo build --release -p ciplink-ffi # ciplink.dll / libciplink.so for C, C++ and C#
Running every suite¶
From the repo root, with W set to the wheel built above (each uv run --isolated uses a fresh environment):
# Rust: the core, the simulator, the C ABI and the differential harness
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
# Python: the public-API tests, and the binding tests (simulator, parity with the reference)
uv run --no-project --isolated -p 3.12 --with $W --with pytest python -m pytest tests/offline
uv run --no-project --isolated -p 3.12 --with $W --with pytest python -m pytest crates/ciplink-py/tests
# the simulator acceptance checks, then the online tests against the simulator
uv run --no-project --isolated -p 3.12 --with $W --with pytest python tools/sim/check_python_against_sim.py
uv run --no-project --isolated -p 3.12 --with $W --with pytest python tools/sim/run_online_tests.py
# C++ and C# (BUILD=cmake builds the C++ tests through CMakeLists.txt instead, e.g. with MSVC)
bindings/cpp/build_and_test.sh
cargo build --release -p ciplink-ffi
(cd bindings/dotnet && dotnet test)
# the reference driver's own tests, when tools/reference changes
uv run --no-project --isolated -p 3.12 --with pytest python -m pytest tools/reference/tests
crates/ciplink-py/tests/test_tagserver.py also runs the ciplink-tagserver suite against the wheel when
CIPLINK_WHEEL names it and a sibling ciplink-tagserver checkout exists; otherwise it is skipped.
crates/ciplink-py/bench/cpu_bench.py and tags_bench.py measure the package against the reference driver.
Fixtures and the reference driver¶
The Rust core is a port of the pure-Python driver kept in tools/reference/ciplink_ref. That driver is the spec
for the wire behaviour:
tools/reference/reference.pyloads it under its original name,ciplink, so its module and logger names stay the ones it always had. Run it in an environment without the installed package (uv run --no-project --isolated);load()refuses to run after anotherciplinkwas imported.tools/fixtures/gen_*.pyrun the reference and write what it sends and returns as JSON fixtures incrates/ciplink/tests/fixtures(and the tables incrates/ciplink/src/tables.rsandlogix/bundle_data.rs). The Rust tests must reproduce them exactly. Regenerate one withuv run --no-project --isolated -p 3.12 python tools/fixtures/gen_codec.py, runcargo fmt --allfor the.rsoutputs, and check withgit diffthat only what you meant to change did change.tools/diff/gen_scenarios.pywritestools/diff/scenarios.json.crates/ciplink-sim/tests/differential.rsruns each scenario with the reference (tools/diff/py_runner.py, started withuv, or the interpreter named byCIPLINK_DIFF_PYTHON) and with the Rust driver against identical simulators, and requires byte-identical request frames and identical results.CIPLINK_DIFF_ONLY=<substring>runs a subset.tools/sim/check_python_against_sim.py --referenceandtools/sim/run_online_tests.py --referencerun the simulator checks with the reference instead of the installed package.
Do not change the reference driver to make a test pass: it records the behaviour the core has to keep.
Continuous integration¶
.github/workflows/ci.yml runs on every push and pull request: cargo fmt --check, clippy with -D warnings and
cargo test --workspace on Linux, Windows (MSVC) and macOS; the Python suites against a maturin develop build on
Python 3.9 and 3.13 (Linux and Windows); the C++ suite with g++ on Linux and with MSVC through CMake on Windows; and
dotnet test on Linux and Windows. Every job runs against ciplink-sim.
Releasing¶
A version tag runs .github/workflows/release.yml, which publishes everything:
| Job | What | Where |
|---|---|---|
wheels, sdist |
abi3 wheels (one per platform, Python 3.9+) for manylinux and musllinux x86_64/aarch64, macOS universal2 and Windows x64, each tested where the runner can run it, plus an sdist | PyPI, trusted publishing (environment pypi) |
crates |
the ciplink crate (its tests and their 17 MB of fixtures stay in the repository) |
crates.io, trusted publishing (environment crates) |
ffi |
crates/ciplink-ffi for linux-x64, win-x64, osx-arm64 and osx-x64: shared, static and import libraries with ciplink.h |
the GitHub release |
nuget |
the Ciplink package with the native library of all four platforms under runtimes/<rid>/native/ |
nuget.org, trusted publishing as RowanRama (environment nuget) |
Each publishing job waits for every wheel to build and pass its tests. To release:
- Make sure CI is green on
main. - Set the new version in
python/ciplink/_version.py,[workspace.package]inCargo.tomland<Version>inbindings/dotnet/Ciplink/Ciplink.csproj(the first job stops the release if they differ from the tag), runcargo checksoCargo.lockfollows, and add the release notes todocs/releases.md. - Commit, then tag and push:
git tag v0.3.0 && git push origin v0.3.0. - Approve the
pypi,cratesandnugetdeployments if those environments require reviewers.
One-time setup: create the environments pypi, crates and nuget on the repository, then add a trusted
publisher for this repository, workflow release.yml, on each registry: PyPI (environment pypi), crates.io
(environment crates, set up after the crate's first manual cargo publish, since crates.io only allows it on an
existing crate) and nuget.org (environment nuget, user RowanRama). No secrets are stored: each publish job
exchanges GitHub's OIDC token for a short-lived registry token.
A failed publish can be re-run from the Actions page once fixed. crates.io, PyPI and nuget.org never accept the same version twice, so a release that went out wrong is fixed with a new version.
New Feature or an Example?¶
It can be tough to decide whether functionality should be added to the library or shown as an example. New features should apply to generally to almost all devices for a driver or implement new functionality that cannot be done externally. If submitting an example, please include name/username/email/etc in a comment/docstring if you wish to be credited.
Here are a couple examples of changes and why they were added either as a feature or example:
[Feature] Add support for writing structures with a dictionary for the value: - Cannot be done without modifying internal methods - New functionality not yet implemented - Improves user experience - user can read a struct, change one value, and write it back without changing the data structure
[Example] Add support for reading/writing Powerflex drive parameters:
- Implemented using the generic_message method
- Does not apply to a wide arrange of device types
- Not a PLC, so doesn't fit in the Logix or SLC drivers
- Too specific for the CIPDriver, but not enough to create a new driver
Some questions to ask yourself when deciding between a feature or an example: - Is this new functionality or a new use of current functionality? Former may be a feature, latter could be an example - Can this be done using already available features? Yes, then maybe an example - Does this apply to a wide arrange of devices? Yes, then maybe a feature - Will this require internal changes to existing functionality? Yes, then maybe a feature - Is this useful? Either should be useful