Skip to content

Contributing

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 fmt and cargo 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 rustfmt and clippy. On Windows, either the MSVC toolchain, or the GNU toolchain with a MinGW-w64 distribution (dlltool, as and g++) on PATH.
  • 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, set CXX for another, or CMake 3.16+ for BUILD=cmake), the .NET SDK 9.0.200 or newer (the solution is a .slnx file; the library targets .NET 8), and optionally cbindgen to regenerate ciplink.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.py loads 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 another ciplink was imported.
  • tools/fixtures/gen_*.py run the reference and write what it sends and returns as JSON fixtures in crates/ciplink/tests/fixtures (and the tables in crates/ciplink/src/tables.rs and logix/bundle_data.rs). The Rust tests must reproduce them exactly. Regenerate one with uv run --no-project --isolated -p 3.12 python tools/fixtures/gen_codec.py, run cargo fmt --all for the .rs outputs, and check with git diff that only what you meant to change did change.
  • tools/diff/gen_scenarios.py writes tools/diff/scenarios.json. crates/ciplink-sim/tests/differential.rs runs each scenario with the reference (tools/diff/py_runner.py, started with uv, or the interpreter named by CIPLINK_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 --reference and tools/sim/run_online_tests.py --reference run 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:

  1. Make sure CI is green on main.
  2. Set the new version in python/ciplink/_version.py, [workspace.package] in Cargo.toml and <Version> in bindings/dotnet/Ciplink/Ciplink.csproj (the first job stops the release if they differ from the tag), run cargo check so Cargo.lock follows, and add the release notes to docs/releases.md.
  3. Commit, then tag and push: git tag v0.3.0 && git push origin v0.3.0.
  4. Approve the pypi, crates and nuget deployments 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