otcat 1.0.0__py3-none-any.whl

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.
otcat/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """otcat: Python bindings for the otcat Go core.
2
+
3
+ A thin, typed wrapper around the real otcat/otc binary -- every read
4
+ and write in this package is executed by the same tested, fuzzed Go
5
+ Modbus TCP client the accompanying paper describes, not a Python
6
+ reimplementation of the protocol. See client.py's module docstring for
7
+ why that's a subprocess boundary rather than a cgo/ctypes one.
8
+
9
+ Quick start::
10
+
11
+ from otcat import Client
12
+
13
+ c = Client("127.0.0.1:502")
14
+ v = c.read("holding:40001")
15
+ print(v.value, v.quality)
16
+
17
+ for v in c.watch("holding:40001", interval="500ms", count=10):
18
+ print(v.ts, v.value)
19
+
20
+ c.write("holding:40001", 100) # confirm=True by default in the library
21
+
22
+ Optional extras (install with e.g. ``pip install otcat[pandas]``):
23
+
24
+ - ``otcat.pandas_ext`` -- DataFrame conversion, a rolling-window buffer
25
+ - ``otcat.alerting`` -- threshold rules with debounce, for automations
26
+ - ``otcat.audit`` -- read-only OT asset discovery / fingerprinting
27
+ - ``otcat.aio`` -- asyncio client for FastAPI and friends
28
+ """
29
+ from .client import Client
30
+ from .exceptions import (
31
+ ConnectionError,
32
+ IOFailureError,
33
+ OtcatBinaryNotFoundError,
34
+ OtcatError,
35
+ ProtocolError,
36
+ Timeout,
37
+ UsageError,
38
+ WriteAbortedError,
39
+ )
40
+ from .models import Value, WritePlan
41
+
42
+ __all__ = [
43
+ "Client",
44
+ "Value",
45
+ "WritePlan",
46
+ "OtcatError",
47
+ "OtcatBinaryNotFoundError",
48
+ "UsageError",
49
+ "ConnectionError",
50
+ "ProtocolError",
51
+ "WriteAbortedError",
52
+ "IOFailureError",
53
+ "Timeout",
54
+ ]
55
+
56
+ __version__ = "1.0.0"
Binary file
otcat/_binary.py ADDED
@@ -0,0 +1,83 @@
1
+ """Locates the otcat binary this package wraps.
2
+
3
+ Resolution order, first match wins:
4
+
5
+ 1. ``OTCAT_BINARY`` environment variable, if set (an explicit escape
6
+ hatch -- useful for pointing at a locally built binary during otcat
7
+ Go-side development, or a version other than the one bundled here).
8
+ 2. A binary bundled inside this wheel, under ``otcat/_bin/``, matching
9
+ the current OS and CPU architecture. Platform-specific wheels (see
10
+ ``docs/packaging.md`` in the Python package) ship exactly one such
11
+ binary; this source checkout ships only ``otcat-linux-amd64``,
12
+ enough to develop and test on the platform this project's own CI
13
+ runs on.
14
+ 3. ``otcat`` or ``otc`` on ``$PATH`` -- covers the case where the Go
15
+ binary was installed separately (``go install``, a system package
16
+ from Cloudsmith, a manual download).
17
+
18
+ If none of these resolve, :class:`OtcatBinaryNotFoundError` explains
19
+ exactly what was tried, so a confusing "file not found" from deep
20
+ inside subprocess machinery never reaches the caller.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import platform
26
+ import shutil
27
+ import stat
28
+ from pathlib import Path
29
+
30
+ from .exceptions import OtcatBinaryNotFoundError
31
+
32
+ _BIN_DIR = Path(__file__).parent / "_bin"
33
+
34
+
35
+ def _platform_tag() -> str:
36
+ system = platform.system().lower()
37
+ machine = platform.machine().lower()
38
+
39
+ os_name = {"darwin": "darwin", "linux": "linux", "windows": "windows"}.get(system, system)
40
+ arch = {
41
+ "x86_64": "amd64", "amd64": "amd64",
42
+ "aarch64": "arm64", "arm64": "arm64",
43
+ }.get(machine, machine)
44
+ return f"{os_name}-{arch}"
45
+
46
+
47
+ def _bundled_path() -> Path | None:
48
+ tag = _platform_tag()
49
+ suffix = ".exe" if tag.startswith("windows") else ""
50
+ candidate = _BIN_DIR / f"otcat-{tag}{suffix}"
51
+ return candidate if candidate.is_file() else None
52
+
53
+
54
+ def find_binary() -> str:
55
+ """Return a path to a working otcat binary, or raise
56
+ :class:`OtcatBinaryNotFoundError` with a clear explanation."""
57
+ tried: list[str] = []
58
+
59
+ env = os.environ.get("OTCAT_BINARY")
60
+ if env:
61
+ tried.append(f"$OTCAT_BINARY={env}")
62
+ if os.path.isfile(env) and os.access(env, os.X_OK):
63
+ return env
64
+
65
+ bundled = _bundled_path()
66
+ if bundled:
67
+ tried.append(f"bundled binary at {bundled}")
68
+ if not os.access(bundled, os.X_OK):
69
+ # Wheel installers don't always preserve the executable
70
+ # bit; fix it rather than fail on something this
71
+ # trivially fixable.
72
+ bundled.chmod(bundled.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
73
+ return str(bundled)
74
+ else:
75
+ tried.append(f"bundled binary for platform '{_platform_tag()}' (none shipped in this install)")
76
+
77
+ for name in ("otcat", "otc"):
78
+ found = shutil.which(name)
79
+ tried.append(f"'{name}' on $PATH")
80
+ if found:
81
+ return found
82
+
83
+ raise OtcatBinaryNotFoundError(tried)
otcat/aio.py ADDED
@@ -0,0 +1,134 @@
1
+ """Async twin of :class:`otcat.client.Client`, built on
2
+ ``asyncio.create_subprocess_exec`` instead of the synchronous
3
+ ``subprocess`` module, so it doesn't block an event loop -- the
4
+ relevant case being a FastAPI endpoint that reads or watches a
5
+ register without stalling every other request being served by the
6
+ same process. Same flags, same JSON parsing, same exception mapping;
7
+ see client.py's module docstring for the design rationale, which
8
+ applies here unchanged.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any
16
+
17
+ from . import _binary
18
+ from .exceptions import Timeout, error_for_exit_code
19
+ from .models import Value, WritePlan
20
+
21
+ _DEFAULT_TIMEOUT = 5.0
22
+
23
+
24
+ class AsyncClient:
25
+ def __init__(
26
+ self,
27
+ endpoint: str,
28
+ *,
29
+ binary: str | None = None,
30
+ unit: int = 1,
31
+ type: str = "uint16", # noqa: A002
32
+ byte_order: str = "big",
33
+ word_order: str = "high",
34
+ raw_address: bool = False,
35
+ timeout: float = _DEFAULT_TIMEOUT,
36
+ ) -> None:
37
+ self.endpoint = endpoint
38
+ self.binary = binary or _binary.find_binary()
39
+ self.unit = unit
40
+ self.type = type
41
+ self.byte_order = byte_order
42
+ self.word_order = word_order
43
+ self.raw_address = raw_address
44
+ self.timeout = timeout
45
+
46
+ def _base_flags(self) -> list[str]:
47
+ flags = [
48
+ "--modbus", self.endpoint,
49
+ "--unit", str(self.unit),
50
+ "--type", self.type,
51
+ "--byte-order", self.byte_order,
52
+ "--word-order", self.word_order,
53
+ "--timeout", f"{self.timeout}s",
54
+ "--json",
55
+ ]
56
+ if self.raw_address:
57
+ flags.append("--raw-address")
58
+ return flags
59
+
60
+ async def _run(self, args: list[str]) -> tuple[int, str, str]:
61
+ proc = await asyncio.create_subprocess_exec(
62
+ self.binary, *args,
63
+ stdout=asyncio.subprocess.PIPE,
64
+ stderr=asyncio.subprocess.PIPE,
65
+ )
66
+ try:
67
+ stdout, stderr = await asyncio.wait_for(
68
+ proc.communicate(), timeout=self.timeout + 5
69
+ )
70
+ except asyncio.TimeoutError as e:
71
+ proc.kill()
72
+ await proc.wait()
73
+ raise Timeout(
74
+ f"otcat process did not exit within {self.timeout + 5}s"
75
+ ) from e
76
+ assert proc.returncode is not None # guaranteed once communicate() has returned
77
+ return proc.returncode, stdout.decode(), stderr.decode()
78
+
79
+ async def read(self, spec: str) -> Value:
80
+ code, out, err = await self._run([*self._base_flags(), "--read", spec])
81
+ if code != 0:
82
+ raise error_for_exit_code(code, err)
83
+ return Value.from_json(json.loads(out.strip().splitlines()[-1]))
84
+
85
+ async def write(self, spec: str, value: Any, *, confirm: bool = True) -> None:
86
+ args = [*self._base_flags(), "--write", spec, "--value", str(value)]
87
+ if confirm:
88
+ args.append("--confirm")
89
+ code, _out, err = await self._run(args)
90
+ if code != 0:
91
+ raise error_for_exit_code(code, err)
92
+
93
+ async def dry_run(self, spec: str, value: Any) -> WritePlan:
94
+ args = [*self._base_flags(), "--write", spec, "--value", str(value), "--dry-run"]
95
+ code, out, err = await self._run(args)
96
+ if code != 0:
97
+ raise error_for_exit_code(code, err)
98
+ return WritePlan.from_json(json.loads(out.strip().splitlines()[-1]))
99
+
100
+ async def watch(
101
+ self, spec: str, *, interval: str = "1s", count: int = 0
102
+ ) -> AsyncIterator[Value]:
103
+ """Async generator streaming readings -- `async for v in
104
+ client.watch(...)`. Ideal for a FastAPI WebSocket endpoint
105
+ forwarding live register values to a browser."""
106
+ args = [
107
+ *self._base_flags(),
108
+ "--watch", spec,
109
+ "--interval", interval,
110
+ "--count", str(count),
111
+ ]
112
+ proc = await asyncio.create_subprocess_exec(
113
+ self.binary, *args,
114
+ stdout=asyncio.subprocess.PIPE,
115
+ stderr=asyncio.subprocess.PIPE,
116
+ )
117
+ try:
118
+ assert proc.stdout is not None
119
+ async for raw_line in proc.stdout:
120
+ line = raw_line.decode().strip()
121
+ if not line:
122
+ continue
123
+ yield Value.from_json(json.loads(line))
124
+ finally:
125
+ if proc.returncode is None:
126
+ proc.terminate()
127
+ try:
128
+ await asyncio.wait_for(proc.wait(), timeout=3)
129
+ except asyncio.TimeoutError:
130
+ proc.kill()
131
+ await proc.wait()
132
+
133
+ def __repr__(self) -> str:
134
+ return f"AsyncClient(endpoint={self.endpoint!r}, unit={self.unit}, type={self.type!r})"
otcat/alerting.py ADDED
@@ -0,0 +1,174 @@
1
+ """A small rule-based alerting engine for turning a watch() stream into
2
+ callbacks -- the building block for "page someone when the tank gets
3
+ above 90%" or "trigger a downstream automation when a coil flips."
4
+
5
+ Deliberately minimal: no rule DSL, no persistence, no distributed
6
+ state. A Rule is a predicate and two callbacks (on_trigger, on_clear);
7
+ the engine's only real logic is debouncing so a value oscillating
8
+ right at a threshold doesn't fire on every single sample.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from .client import Client
19
+ from .models import Value
20
+
21
+ logger = logging.getLogger("otcat.alerting")
22
+
23
+
24
+ @dataclass
25
+ class Rule:
26
+ """spec: address to watch. predicate: called with the decoded
27
+ value; return True when the alert condition is met. on_trigger is
28
+ called once when the rule transitions False->True (after
29
+ `debounce` consecutive breaching samples, not on the first one --
30
+ see AlertEngine's docstring for why). on_clear, if given, is
31
+ called once on the True->False transition."""
32
+
33
+ spec: str
34
+ predicate: Callable[[Any], bool]
35
+ on_trigger: Callable[[Value], None]
36
+ on_clear: Callable[[Value], None] | None = None
37
+ debounce: int = 1
38
+ name: str = ""
39
+
40
+ # internal state
41
+ _consecutive: int = field(default=0, repr=False, compare=False)
42
+ _active: bool = field(default=False, repr=False, compare=False)
43
+
44
+ def __post_init__(self):
45
+ self.name = self.name or self.spec
46
+
47
+
48
+ @dataclass
49
+ class Threshold:
50
+ """A Rule built from a plain numeric threshold, for the common
51
+ case that doesn't need a custom predicate.
52
+
53
+ >>> Threshold(spec="holding:0", above=9000).as_rule(on_trigger=page_oncall)
54
+ """
55
+
56
+ spec: str
57
+ above: float | None = None
58
+ below: float | None = None
59
+ debounce: int = 3
60
+ name: str = ""
61
+
62
+ def as_rule(
63
+ self,
64
+ on_trigger: Callable[[Value], None],
65
+ on_clear: Callable[[Value], None] | None = None,
66
+ ) -> Rule:
67
+ above, below = self.above, self.below
68
+
69
+ def predicate(value: Any) -> bool:
70
+ v = float(value)
71
+ if above is not None and v > above:
72
+ return True
73
+ if below is not None and v < below:
74
+ return True
75
+ return False
76
+
77
+ return Rule(
78
+ spec=self.spec,
79
+ predicate=predicate,
80
+ on_trigger=on_trigger,
81
+ on_clear=on_clear,
82
+ debounce=self.debounce,
83
+ name=self.name or self.spec,
84
+ )
85
+
86
+
87
+ class AlertEngine:
88
+ """Polls a set of Rules, one otcat --watch subprocess per unique
89
+ spec, and dispatches on_trigger/on_clear callbacks with debounce.
90
+
91
+ Debounce exists because a raw threshold on a live signal will
92
+ otherwise fire, clear, and re-fire every time noise crosses the
93
+ line -- exactly the alert-fatigue failure mode any real automation
94
+ or paging system needs to avoid. `debounce=N` requires N
95
+ consecutive breaching samples before on_trigger fires, and (by the
96
+ same logic, for the same reason) N consecutive non-breaching
97
+ samples before on_clear fires.
98
+
99
+ This runs rules sequentially in one thread by design: it is meant
100
+ for a handful of rules on a slow-moving industrial process (seconds
101
+ between readings, not microseconds), not a high-frequency
102
+ multiplexed data feed. For dozens of independent high-frequency
103
+ watches, run separate AlertEngine instances (or Client.watch()
104
+ loops) in separate threads/processes instead of adding concurrency
105
+ complexity to this class.
106
+ """
107
+
108
+ def __init__(self, client: Client):
109
+ self.client = client
110
+ self._rules: list[Rule] = []
111
+
112
+ def add_rule(self, rule: Rule) -> "AlertEngine":
113
+ self._rules.append(rule)
114
+ return self
115
+
116
+ def threshold(
117
+ self,
118
+ spec: str,
119
+ *,
120
+ above: float | None = None,
121
+ below: float | None = None,
122
+ on_trigger: Callable[[Value], None],
123
+ on_clear: Callable[[Value], None] | None = None,
124
+ debounce: int = 3,
125
+ name: str = "",
126
+ ) -> "AlertEngine":
127
+ """Convenience: build and add a Threshold rule in one call."""
128
+ rule = Threshold(spec=spec, above=above, below=below, debounce=debounce, name=name).as_rule(
129
+ on_trigger=on_trigger, on_clear=on_clear
130
+ )
131
+ return self.add_rule(rule)
132
+
133
+ def poll_once(self) -> None:
134
+ """Read every rule's spec once and evaluate it. Useful for
135
+ testing a rule set, or for driving the engine from your own
136
+ scheduler instead of run()'s built-in loop."""
137
+ for rule in self._rules:
138
+ try:
139
+ value = self.client.read(rule.spec)
140
+ except Exception:
141
+ logger.exception("otcat alerting: read failed for rule %r", rule.name)
142
+ continue
143
+ self._evaluate(rule, value)
144
+
145
+ def _evaluate(self, rule: Rule, value: Value) -> None:
146
+ breached = False
147
+ try:
148
+ breached = rule.predicate(value.value)
149
+ except Exception:
150
+ logger.exception("otcat alerting: predicate failed for rule %r", rule.name)
151
+ return
152
+
153
+ if breached:
154
+ rule._consecutive += 1
155
+ if not rule._active and rule._consecutive >= rule.debounce:
156
+ rule._active = True
157
+ rule.on_trigger(value)
158
+ else:
159
+ rule._consecutive = 0
160
+ if rule._active:
161
+ rule._active = False
162
+ if rule.on_clear:
163
+ rule.on_clear(value)
164
+
165
+ def run(self, *, interval_seconds: float = 1.0, iterations: int | None = None) -> None:
166
+ """Blocking loop: poll_once() every interval_seconds. Run this
167
+ in a background thread for a long-lived service; iterations
168
+ (mainly for tests) bounds the loop instead of running forever."""
169
+ i = 0
170
+ while iterations is None or i < iterations:
171
+ self.poll_once()
172
+ i += 1
173
+ if iterations is None or i < iterations:
174
+ time.sleep(interval_seconds)
otcat/audit.py ADDED
@@ -0,0 +1,156 @@
1
+ """Read-only auditing helpers for OT asset discovery and exposure
2
+ assessment -- the Python-side equivalent of the Go core's own
3
+ safety-first ethos (see the accompanying paper, "Safety Is a Default,
4
+ Not a Feature"): every function in this module only ever issues reads.
5
+ There is no scan-and-write mode, no fuzzer, no credential attack (moot
6
+ anyway -- Modbus has no authentication to attack), and no function that
7
+ sends more than one request at a time to a single device. That is a
8
+ deliberate, permanent design boundary for this module, not a version-1
9
+ limitation to be lifted later.
10
+
11
+ Intended use: legitimate asset inventory, exposure mapping, and
12
+ red-team reconnaissance *you are authorized to perform* against
13
+ equipment you are authorized to test. Running any scanner, including
14
+ this one, against equipment you do not have explicit authorization to
15
+ test may be illegal in your jurisdiction and can disrupt a live
16
+ physical process regardless of intent -- see the Go core's own
17
+ Limitations section for why Modbus's total absence of authentication
18
+ means "I can reach it" has never implied "I am allowed to."
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from dataclasses import dataclass, field
24
+
25
+ from .client import Client
26
+ from .exceptions import ConnectionError as OtcatConnectionError
27
+ from .exceptions import ProtocolError
28
+
29
+
30
+ @dataclass
31
+ class ScanResult:
32
+ table: str
33
+ address: int
34
+ responsive: bool
35
+ exception_code: str | None = None
36
+ latency_ms: float | None = None
37
+
38
+
39
+ @dataclass
40
+ class ScanReport:
41
+ endpoint: str
42
+ table: str
43
+ start: int
44
+ count: int
45
+ results: list[ScanResult] = field(default_factory=list)
46
+
47
+ @property
48
+ def responsive_addresses(self) -> list[int]:
49
+ return [r.address for r in self.results if r.responsive]
50
+
51
+ @property
52
+ def response_rate(self) -> float:
53
+ return len(self.responsive_addresses) / len(self.results) if self.results else 0.0
54
+
55
+ def summary(self) -> str:
56
+ lat = [r.latency_ms for r in self.results if r.latency_ms is not None]
57
+ avg_lat = sum(lat) / len(lat) if lat else float("nan")
58
+ return (
59
+ f"{self.endpoint} {self.table}:{self.start}..{self.start + self.count - 1}: "
60
+ f"{len(self.responsive_addresses)}/{len(self.results)} addresses responsive "
61
+ f"({self.response_rate:.0%}), avg latency {avg_lat:.1f}ms"
62
+ )
63
+
64
+
65
+ def scan_range(
66
+ client: Client,
67
+ table: str,
68
+ start: int,
69
+ count: int,
70
+ *,
71
+ delay_seconds: float = 0.0,
72
+ ) -> ScanReport:
73
+ """Sweep [start, start+count) with individual single-value reads,
74
+ recording which addresses respond, which raise a protocol
75
+ exception (and which one), and per-address latency. Read-only,
76
+ always -- see the module docstring.
77
+
78
+ delay_seconds paces requests between addresses; the default (0)
79
+ sends as fast as the client allows, which is fine for otcat's own
80
+ mock server and most modern devices, but consider a small delay
81
+ (e.g. 0.05) against older or resource-constrained field hardware,
82
+ the same courtesy the paper's own write-safety design extends
83
+ throughout: a scanner should not become a denial-of-service tool
84
+ by accident.
85
+ """
86
+ report = ScanReport(endpoint=client.endpoint, table=table, start=start, count=count)
87
+ for offset in range(count):
88
+ addr = start + offset
89
+ t0 = time.perf_counter()
90
+ try:
91
+ client.read(f"{table}:{addr}")
92
+ latency_ms = (time.perf_counter() - t0) * 1000
93
+ report.results.append(ScanResult(table, addr, responsive=True, latency_ms=latency_ms))
94
+ except ProtocolError as e:
95
+ latency_ms = (time.perf_counter() - t0) * 1000
96
+ report.results.append(
97
+ ScanResult(table, addr, responsive=False, exception_code=e.exception_code, latency_ms=latency_ms)
98
+ )
99
+ except OtcatConnectionError:
100
+ report.results.append(ScanResult(table, addr, responsive=False))
101
+ if delay_seconds:
102
+ time.sleep(delay_seconds)
103
+ return report
104
+
105
+
106
+ @dataclass
107
+ class DeviceFingerprint:
108
+ endpoint: str
109
+ reachable: bool
110
+ tables_responsive: dict[str, bool] = field(default_factory=dict)
111
+ latency_ms_p50: float | None = None
112
+ latency_ms_p99: float | None = None
113
+ samples: int = 0
114
+
115
+
116
+ def fingerprint(client: Client, *, samples: int = 20, probe_address: int = 0) -> DeviceFingerprint:
117
+ """A lightweight, read-only device fingerprint: which of the four
118
+ Modbus data tables respond at all at probe_address, and the
119
+ round-trip latency distribution over `samples` reads of one
120
+ holding register -- useful groundwork for both an asset inventory
121
+ entry and a latency budget for how aggressively you can safely
122
+ poll this specific device (see the paper's own methodology for why
123
+ latency percentiles, not just a mean, are the right thing to record
124
+ here).
125
+ """
126
+ fp = DeviceFingerprint(endpoint=client.endpoint, reachable=False)
127
+
128
+ for table in ("holding", "input", "coil", "discrete"):
129
+ try:
130
+ client.read(f"{table}:{probe_address}")
131
+ fp.tables_responsive[table] = True
132
+ fp.reachable = True
133
+ except ProtocolError:
134
+ # Reached the device; this table/address combination just
135
+ # isn't valid there. Still evidence of reachability.
136
+ fp.tables_responsive[table] = False
137
+ fp.reachable = True
138
+ except OtcatConnectionError:
139
+ fp.tables_responsive[table] = False
140
+
141
+ if fp.reachable:
142
+ latencies: list[float] = []
143
+ for _ in range(samples):
144
+ t0 = time.perf_counter()
145
+ try:
146
+ client.read(f"holding:{probe_address}")
147
+ latencies.append((time.perf_counter() - t0) * 1000)
148
+ except (ProtocolError, OtcatConnectionError):
149
+ pass
150
+ if latencies:
151
+ latencies.sort()
152
+ fp.samples = len(latencies)
153
+ fp.latency_ms_p50 = latencies[len(latencies) // 2]
154
+ fp.latency_ms_p99 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.99))]
155
+
156
+ return fp
otcat/client.py ADDED
@@ -0,0 +1,188 @@
1
+ """The synchronous otcat client.
2
+
3
+ This is deliberately a subprocess wrapper around the real, compiled Go
4
+ binary -- not a reimplementation of the Modbus protocol in Python.
5
+ Every byte that crosses a real socket is still produced and parsed by
6
+ the same Go core the paper describes and the Go test suite exercises;
7
+ this package's only job is turning that binary's ndjson stdout into
8
+ typed Python objects, and Python calls into the right CLI flags.
9
+
10
+ Why subprocess and not a cgo shared library / ctypes binding: it is
11
+ the integration surface otcat was already designed around (stdin,
12
+ stdout, one ndjson object per line), it costs nothing extra to keep
13
+ correct as the Go core evolves, and it works on every platform the Go
14
+ binary itself supports with zero additional build tooling on the
15
+ Python side. The cost is one process-spawn per read/write and a few
16
+ milliseconds of overhead -- immaterial next to a Modbus round trip
17
+ (see the paper's own latency figures) and irrelevant for --watch,
18
+ which spawns once and streams.
19
+
20
+ Write safety: exactly one behavior carries over from the CLI without
21
+ a Python-side opinion layered on top -- Client.write() defaults to
22
+ confirm=True, deliberately the *opposite* default of the CLI's own
23
+ --confirm flag. The CLI defaults to refusing because a human might be
24
+ about to fat-finger a command; a library call is already the result of
25
+ a programmer deciding, in code, to write a specific value -- there is
26
+ no keystroke left to protect against. If you want the CLI's stricter
27
+ default in your own code, pass confirm=False and handle
28
+ WriteAbortedError.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import subprocess
34
+ from collections.abc import Iterator
35
+ from typing import Any
36
+
37
+ from . import _binary
38
+ from .exceptions import Timeout, error_for_exit_code
39
+ from .models import Value, WritePlan
40
+
41
+ _DEFAULT_TIMEOUT = 5.0
42
+
43
+
44
+ class Client:
45
+ def __init__(
46
+ self,
47
+ endpoint: str,
48
+ *,
49
+ binary: str | None = None,
50
+ unit: int = 1,
51
+ type: str = "uint16", # noqa: A002 - matches the CLI's --type name
52
+ byte_order: str = "big",
53
+ word_order: str = "high",
54
+ raw_address: bool = False,
55
+ timeout: float = _DEFAULT_TIMEOUT,
56
+ ) -> None:
57
+ """endpoint: "host:port" of a Modbus TCP device or otcat-mockplc."""
58
+ self.endpoint = endpoint
59
+ self.binary = binary or _binary.find_binary()
60
+ self.unit = unit
61
+ self.type = type
62
+ self.byte_order = byte_order
63
+ self.word_order = word_order
64
+ self.raw_address = raw_address
65
+ self.timeout = timeout
66
+
67
+ # -- shared flag building -------------------------------------------------
68
+
69
+ def _base_flags(self) -> list[str]:
70
+ flags = [
71
+ "--modbus", self.endpoint,
72
+ "--unit", str(self.unit),
73
+ "--type", self.type,
74
+ "--byte-order", self.byte_order,
75
+ "--word-order", self.word_order,
76
+ "--timeout", f"{self.timeout}s",
77
+ "--json",
78
+ ]
79
+ if self.raw_address:
80
+ flags.append("--raw-address")
81
+ return flags
82
+
83
+ def _run(self, args: list[str], input_text: str | None = None) -> subprocess.CompletedProcess:
84
+ try:
85
+ return subprocess.run(
86
+ [self.binary, *args],
87
+ input=input_text,
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=self.timeout + 5, # generous margin over otcat's own --timeout
91
+ )
92
+ except subprocess.TimeoutExpired as e:
93
+ raise Timeout(
94
+ f"otcat process did not exit within {self.timeout + 5}s "
95
+ f"(otcat's own --timeout is {self.timeout}s; this is the "
96
+ f"Python-side watchdog on top of it)"
97
+ ) from e
98
+
99
+ # -- public API -------------------------------------------------------------
100
+
101
+ def read(self, spec: str) -> Value:
102
+ """Read one address once. spec: e.g. "holding:40001" or
103
+ "holding:200:2" for a 2-register array."""
104
+ proc = self._run([*self._base_flags(), "--read", spec])
105
+ if proc.returncode != 0:
106
+ raise error_for_exit_code(proc.returncode, proc.stderr)
107
+ return Value.from_json(json.loads(proc.stdout.strip().splitlines()[-1]))
108
+
109
+ def read_many(self, specs: list[str]) -> list[Value]:
110
+ """Convenience: read() each spec in sequence (N processes, N
111
+ round trips -- not a single batched Modbus request, since
112
+ otcat's own protocol layer doesn't batch either; see the
113
+ paper's discussion of why the client stays single-request)."""
114
+ return [self.read(spec) for spec in specs]
115
+
116
+ def write(
117
+ self,
118
+ spec: str,
119
+ value: Any,
120
+ *,
121
+ confirm: bool = True,
122
+ ) -> None:
123
+ """Write value to spec. See the module docstring for why
124
+ confirm defaults to True here, opposite the CLI's own default."""
125
+ args = [*self._base_flags(), "--write", spec, "--value", str(value)]
126
+ if confirm:
127
+ args.append("--confirm")
128
+ proc = self._run(args)
129
+ if proc.returncode != 0:
130
+ raise error_for_exit_code(proc.returncode, proc.stderr)
131
+
132
+ def dry_run(self, spec: str, value: Any) -> WritePlan:
133
+ """Compute exactly what write(spec, value) would send, with
134
+ zero network I/O -- see internal/cliapp's --dry-run in the Go
135
+ core; this calls the identical code path."""
136
+ args = [*self._base_flags(), "--write", spec, "--value", str(value), "--dry-run"]
137
+ proc = self._run(args)
138
+ if proc.returncode != 0:
139
+ raise error_for_exit_code(proc.returncode, proc.stderr)
140
+ return WritePlan.from_json(json.loads(proc.stdout.strip().splitlines()[-1]))
141
+
142
+ def watch(
143
+ self,
144
+ spec: str,
145
+ *,
146
+ interval: str = "1s",
147
+ count: int = 0,
148
+ ) -> Iterator[Value]:
149
+ """Stream readings as they arrive. A generator wrapping one
150
+ long-lived `otcat --watch` subprocess -- values are yielded the
151
+ instant otcat's own stdout flushes them (line-buffered, exactly
152
+ as documented in the Go codec package), not batched.
153
+
154
+ count=0 (the default) streams until the caller stops iterating
155
+ (breaking out of a `for` loop terminates the subprocess
156
+ cleanly) or the process is otherwise interrupted.
157
+ """
158
+ args = [
159
+ *self._base_flags(),
160
+ "--watch", spec,
161
+ "--interval", interval,
162
+ "--count", str(count),
163
+ ]
164
+ proc = subprocess.Popen(
165
+ [self.binary, *args],
166
+ stdout=subprocess.PIPE,
167
+ stderr=subprocess.PIPE,
168
+ text=True,
169
+ bufsize=1, # line-buffered
170
+ )
171
+ try:
172
+ assert proc.stdout is not None
173
+ for line in proc.stdout:
174
+ line = line.strip()
175
+ if not line:
176
+ continue
177
+ yield Value.from_json(json.loads(line))
178
+ finally:
179
+ if proc.poll() is None:
180
+ proc.terminate()
181
+ try:
182
+ proc.wait(timeout=3)
183
+ except subprocess.TimeoutExpired:
184
+ proc.kill()
185
+ proc.wait()
186
+
187
+ def __repr__(self) -> str:
188
+ return f"Client(endpoint={self.endpoint!r}, unit={self.unit}, type={self.type!r})"
otcat/exceptions.py ADDED
@@ -0,0 +1,84 @@
1
+ """otcat's Python exceptions mirror the Go CLI's exit-code contract
2
+ (see the main project's README, "Exit codes" table) one-for-one, so
3
+ catching a specific class tells you exactly what class of failure
4
+ happened without parsing error text.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ class OtcatError(Exception):
10
+ """Base class for every error this package raises."""
11
+
12
+
13
+ class OtcatBinaryNotFoundError(OtcatError):
14
+ """No otcat/otc binary could be located."""
15
+
16
+ def __init__(self, tried: list[str]):
17
+ self.tried = tried
18
+ msg = (
19
+ "could not find an otcat binary. Tried, in order:\n - "
20
+ + "\n - ".join(tried)
21
+ + "\n\nFix: install the Go binary (see "
22
+ "https://github.com/QuitOperation/otcat#install--build) and "
23
+ "ensure it's on $PATH, or set OTCAT_BINARY to its exact path."
24
+ )
25
+ super().__init__(msg)
26
+
27
+
28
+ class UsageError(OtcatError):
29
+ """Exit code 1: bad arguments, malformed address spec, bad literal.
30
+ Almost always a bug in the calling code, not a transient condition."""
31
+
32
+
33
+ class ConnectionError(OtcatError): # noqa: A001 - deliberately shadows builtin, mirrors Go's naming
34
+ """Exit code 2: dial failure, timeout, connection refused."""
35
+
36
+
37
+ class ProtocolError(OtcatError):
38
+ """Exit code 3: the device returned a real Modbus exception
39
+ (illegal address, illegal value, etc). See .exception_code."""
40
+
41
+ def __init__(self, message: str, exception_code: str | None = None):
42
+ super().__init__(message)
43
+ self.exception_code = exception_code
44
+
45
+
46
+ class WriteAbortedError(OtcatError):
47
+ """Exit code 4: a write was refused because it was not confirmed.
48
+ This is otcat's safety gate working as designed, not a bug --
49
+ catching this and silently retrying with confirm=True defeats the
50
+ entire point of the gate. See client.py's module docstring."""
51
+
52
+
53
+ class IOFailureError(OtcatError):
54
+ """Exit code 5: e.g. a broken output pipe, or an otherwise
55
+ unclassified failure."""
56
+
57
+
58
+ class Timeout(OtcatError):
59
+ """Raised by watch()/read() when a client-side Python timeout
60
+ (distinct from otcat's own --timeout) elapses -- e.g. a watch
61
+ generator that produced no value for longer than expected."""
62
+
63
+
64
+ _EXIT_CODE_MAP = {
65
+ 1: UsageError,
66
+ 2: ConnectionError,
67
+ 3: ProtocolError,
68
+ 4: WriteAbortedError,
69
+ 5: IOFailureError,
70
+ }
71
+
72
+
73
+ def error_for_exit_code(code: int, stderr: str) -> OtcatError:
74
+ cls = _EXIT_CODE_MAP.get(code, OtcatError)
75
+ message = stderr.strip() or f"otcat exited with code {code}"
76
+ if cls is ProtocolError:
77
+ # otcat's protocol-error stderr looks like:
78
+ # otcat: read holding:0: modbus: unit 1 function 0x03: illegal data address (0x02)
79
+ exc_code = None
80
+ if "(0x" in message:
81
+ exc_code = message.rsplit("(0x", 1)[-1].rstrip(")")
82
+ exc_code = "0x" + exc_code
83
+ return ProtocolError(message, exception_code=exc_code)
84
+ return cls(message)
otcat/models.py ADDED
@@ -0,0 +1,75 @@
1
+ """Typed models mirroring the JSON otcat's Go core actually emits
2
+ (``internal/protocol.Value`` and ``internal/protocol.WritePlan``).
3
+ Nothing here is reimplemented protocol logic -- it's just a typed
4
+ Python view of exactly the bytes the Go binary printed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Value:
15
+ """One reading. Field-for-field the same shape as the ndjson
16
+ otcat --json prints, so ``Value(**json.loads(line))`` (after
17
+ renaming ``ts``) is a valid way to think about this type even
18
+ though the real parsing goes through :func:`from_json`."""
19
+
20
+ address: str
21
+ type: str
22
+ value: Any
23
+ quality: str
24
+ ts: datetime
25
+ raw: list[int] | None = None
26
+
27
+ @property
28
+ def good(self) -> bool:
29
+ return self.quality == "good"
30
+
31
+ @classmethod
32
+ def from_json(cls, obj: dict) -> "Value":
33
+ ts_raw = obj["ts"]
34
+ # Go's RFC3339Nano sometimes omits the fractional seconds
35
+ # entirely (whole-second timestamps); Python's fromisoformat
36
+ # (3.11+) handles the 'Z' suffix natively, but we support 3.9+
37
+ # so normalize it ourselves.
38
+ ts_norm = ts_raw.replace("Z", "+00:00")
39
+ try:
40
+ ts = datetime.fromisoformat(ts_norm)
41
+ except ValueError:
42
+ ts = datetime.now(timezone.utc)
43
+ return cls(
44
+ address=obj["address"],
45
+ type=obj["type"],
46
+ value=obj["value"],
47
+ quality=obj["quality"],
48
+ ts=ts,
49
+ raw=obj.get("raw"),
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class WritePlan:
55
+ """The output of Client.dry_run(): exactly what a write would
56
+ send, computed with zero network I/O (mirrors
57
+ internal/protocol.WritePlan)."""
58
+
59
+ driver: str
60
+ address: str
61
+ literal: str
62
+ type: str | None = None
63
+ registers: list[int] = field(default_factory=list)
64
+ coils: list[bool] = field(default_factory=list)
65
+
66
+ @classmethod
67
+ def from_json(cls, obj: dict) -> "WritePlan":
68
+ return cls(
69
+ driver=obj["driver"],
70
+ address=obj["address"],
71
+ literal=obj["literal"],
72
+ type=obj.get("type"),
73
+ registers=obj.get("registers", []) or [],
74
+ coils=obj.get("coils", []) or [],
75
+ )
otcat/pandas_ext.py ADDED
@@ -0,0 +1,103 @@
1
+ """pandas integration. Kept as a separate, optional-import module
2
+ (pandas is not a hard dependency of otcat's core client -- see
3
+ pyproject.toml's [project.optional-dependencies]) so anything that
4
+ only needs Client/AsyncClient never has to install pandas at all.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterable
9
+ from typing import TYPE_CHECKING
10
+
11
+ from .client import Client
12
+ from .models import Value
13
+
14
+ if TYPE_CHECKING:
15
+ import pandas as pd
16
+
17
+ _MISSING_PANDAS = (
18
+ "pandas is required for otcat.pandas_ext but is not installed. "
19
+ "Install it with: pip install otcat[pandas]"
20
+ )
21
+
22
+
23
+ def _require_pandas():
24
+ try:
25
+ import pandas as pd
26
+ except ImportError as e: # pragma: no cover - exercised only without pandas installed
27
+ raise ImportError(_MISSING_PANDAS) from e
28
+ return pd
29
+
30
+
31
+ def values_to_dataframe(values: Iterable[Value]) -> "pd.DataFrame":
32
+ """Convert any iterable of Value (e.g. Client.read_many's return,
33
+ or a fully-consumed Client.watch() generator) into a DataFrame with
34
+ columns [address, type, value, quality, ts] and ts as the index --
35
+ ready for `.resample()`, `.rolling()`, or a straight `.plot()`."""
36
+ pd = _require_pandas()
37
+ rows = [
38
+ {
39
+ "ts": v.ts,
40
+ "address": v.address,
41
+ "type": v.type,
42
+ "value": v.value,
43
+ "quality": v.quality,
44
+ }
45
+ for v in values
46
+ ]
47
+ df = pd.DataFrame(rows)
48
+ if not df.empty:
49
+ df = df.set_index("ts")
50
+ return df
51
+
52
+
53
+ def read_many_df(client: Client, specs: list[str]) -> "pd.DataFrame":
54
+ """client.read_many(specs), as a DataFrame. One row per spec."""
55
+ return values_to_dataframe(client.read_many(specs))
56
+
57
+
58
+ def watch_df(
59
+ client: Client,
60
+ spec: str,
61
+ *,
62
+ count: int,
63
+ interval: str = "1s",
64
+ ) -> "pd.DataFrame":
65
+ """Block until `count` samples are collected via client.watch(),
66
+ then return them as one DataFrame. For an unbounded live stream
67
+ instead, iterate client.watch() directly and accumulate/append
68
+ however your application (a dashboard, a rolling buffer) wants --
69
+ a single ever-growing DataFrame is usually the wrong shape for a
70
+ truly unbounded stream, which is why this helper requires a finite
71
+ count rather than offering an unbounded DataFrame-producing mode."""
72
+ values = list(client.watch(spec, interval=interval, count=count))
73
+ return values_to_dataframe(values)
74
+
75
+
76
+ class RollingBuffer:
77
+ """A fixed-size rolling window over a live watch() stream,
78
+ materialized as a DataFrame on demand -- the shape a live
79
+ dashboard or a streaming feature-engineering step usually wants:
80
+ bounded memory, always-current, no unbounded accumulation.
81
+
82
+ >>> client = Client("127.0.0.1:502")
83
+ >>> buf = RollingBuffer(maxlen=500)
84
+ >>> for v in client.watch("holding:0", interval="200ms"):
85
+ ... buf.push(v)
86
+ ... if len(buf) >= 10:
87
+ ... df = buf.to_dataframe()
88
+ ... # df.value.rolling(5).mean(), df.plot(), feed a model, etc.
89
+ """
90
+
91
+ def __init__(self, maxlen: int = 1000):
92
+ from collections import deque
93
+ self.maxlen = maxlen
94
+ self._buf: deque[Value] = deque(maxlen=maxlen)
95
+
96
+ def push(self, value: Value) -> None:
97
+ self._buf.append(value)
98
+
99
+ def __len__(self) -> int:
100
+ return len(self._buf)
101
+
102
+ def to_dataframe(self) -> "pd.DataFrame":
103
+ return values_to_dataframe(list(self._buf))
otcat/py.typed ADDED
File without changes
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: otcat
3
+ Version: 1.0.0
4
+ Summary: Python bindings for otcat, the netcat for industrial I/O -- reads and writes Modbus TCP through the real Go core.
5
+ Project-URL: Homepage, https://github.com/QuitOperation/otcat
6
+ Project-URL: Go core, https://github.com/QuitOperation/otcat
7
+ Project-URL: Documentation, https://github.com/QuitOperation/otcat/tree/main/python
8
+ Project-URL: Issues, https://github.com/QuitOperation/otcat/issues
9
+ Author-email: Moustafa Mahmoud Atta <MoustafaAt1a@outlook.com>
10
+ License: MIT
11
+ Keywords: automation,ics,industrial,modbus,ot,plc,scada
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: System :: Hardware
24
+ Classifier: Topic :: System :: Networking
25
+ Requires-Python: >=3.9
26
+ Provides-Extra: all
27
+ Requires-Dist: fastapi>=0.100; extra == 'all'
28
+ Requires-Dist: pandas>=1.5; extra == 'all'
29
+ Requires-Dist: streamlit>=1.28; extra == 'all'
30
+ Requires-Dist: uvicorn>=0.23; extra == 'all'
31
+ Provides-Extra: dashboard
32
+ Requires-Dist: streamlit>=1.28; extra == 'dashboard'
33
+ Provides-Extra: dev
34
+ Requires-Dist: mypy>=1.5; extra == 'dev'
35
+ Requires-Dist: pandas>=1.5; extra == 'dev'
36
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
37
+ Requires-Dist: pytest>=7.4; extra == 'dev'
38
+ Requires-Dist: ruff>=0.1; extra == 'dev'
39
+ Provides-Extra: fastapi
40
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
41
+ Requires-Dist: uvicorn>=0.23; extra == 'fastapi'
42
+ Provides-Extra: pandas
43
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # otcat (Python)
47
+
48
+ Python bindings for [otcat](https://github.com/QuitOperation/otcat),
49
+ the netcat for industrial I/O. This package wraps the real, compiled
50
+ Go binary — every Modbus read and write is still executed by the same
51
+ tested, fuzzed Go core the main project's paper describes, not a
52
+ Python reimplementation of the protocol.
53
+
54
+ ```python
55
+ from otcat import Client
56
+
57
+ c = Client("127.0.0.1:502")
58
+ v = c.read("holding:40001")
59
+ print(v.value, v.quality, v.ts)
60
+
61
+ for v in c.watch("holding:40001", interval="500ms", count=10):
62
+ print(v.ts, v.value)
63
+
64
+ c.write("holding:40001", 100) # confirm=True by default -- see "Write safety" below
65
+ ```
66
+
67
+ ## Install
68
+
69
+ ```sh
70
+ pip install otcat # core client only, zero extra dependencies
71
+ pip install otcat[pandas] # + DataFrame helpers
72
+ pip install otcat[fastapi] # + the async client's natural home
73
+ pip install otcat[dashboard] # + Streamlit
74
+ pip install otcat[all] # everything
75
+ ```
76
+
77
+ The Go binary itself ships bundled inside platform-specific wheels; if
78
+ none is available for your platform, install it separately (`go
79
+ install github.com/QuitOperation/otcat/cmd/otcat@latest`, or one of the
80
+ [Cloudsmith packages](../docs/releasing.md)) and either put it on
81
+ `$PATH` or point `OTCAT_BINARY` at it directly. See
82
+ [`docs/packaging.md`](docs/packaging.md) for exactly how the bundled
83
+ binary is resolved.
84
+
85
+ ## Write safety: one deliberate difference from the CLI
86
+
87
+ The Go CLI refuses every write by default unless `--confirm` is passed
88
+ or an interactive operator answers a y/N prompt — the right default
89
+ for a human typing a command who might have a typo. `Client.write()`
90
+ defaults to `confirm=True` instead, because a library call is already
91
+ the result of a programmer's code deciding, deliberately, to write a
92
+ specific value; there is no keystroke left to protect against. Pass
93
+ `confirm=False` if you want the stricter behavior and are prepared to
94
+ catch `WriteAbortedError`.
95
+
96
+ ## Modules
97
+
98
+ | Module | What it's for |
99
+ |---|---|
100
+ | `otcat.Client` | Synchronous read/write/watch/dry_run — the core |
101
+ | `otcat.aio.AsyncClient` | Same API, `asyncio`-native — FastAPI, WebSockets |
102
+ | `otcat.pandas_ext` | `Value` lists ↔ `DataFrame`, plus a bounded `RollingBuffer` for live dashboards |
103
+ | `otcat.alerting` | Threshold rules with debounce → callbacks, for automations and paging |
104
+ | `otcat.audit` | Read-only OT asset discovery / device fingerprinting (never writes — see its module docstring) |
105
+
106
+ ## Examples
107
+
108
+ See [`examples/`](examples/): a pandas time-series pull, a FastAPI
109
+ service with a live WebSocket, a Streamlit dashboard, a threshold
110
+ alerting script, and a read-only network audit script.
111
+
112
+ ## Exit codes → exceptions
113
+
114
+ | Go CLI exit code | Python exception |
115
+ |---|---|
116
+ | 1 | `otcat.UsageError` |
117
+ | 2 | `otcat.ConnectionError` |
118
+ | 3 | `otcat.ProtocolError` (`.exception_code` holds the Modbus exception, e.g. `"0x02"`) |
119
+ | 4 | `otcat.WriteAbortedError` |
120
+ | 5 | `otcat.IOFailureError` |
121
+ | (n/a) | `otcat.Timeout` — a Python-side watchdog, distinct from otcat's own `--timeout` |
122
+
123
+ ## Testing
124
+
125
+ ```sh
126
+ pip install -e ".[dev]"
127
+ pytest
128
+ ```
129
+
130
+ Every test is an integration test against a freshly spawned
131
+ `otcat-mockplc` — see `tests/conftest.py`. Building `otcat-mockplc`
132
+ requires a Go 1.22+ toolchain on `$PATH` at test time (the shipped
133
+ wheel itself needs no Go toolchain to *use*, only this repo's test
134
+ suite needs one to build its own test fixture).
135
+
136
+ ## License
137
+
138
+ MIT — same as the Go core.
@@ -0,0 +1,14 @@
1
+ otcat/__init__.py,sha256=E-b9-DPbwOLQBBsnW4SydBefRRj6OOgLH4V6ff7dqPQ,1547
2
+ otcat/_binary.py,sha256=N4001fu-m7XyxunqMyCdPCAXy2TGqQi6ub6q7fP85jc,2928
3
+ otcat/aio.py,sha256=1NoWGvy-lWNRvZcZw41Y2-M_eLZyuAgG5azCjr01Xqw,4855
4
+ otcat/alerting.py,sha256=iL0xjQr4bu6B3_LUCwZy4__y7LDVouTxmJCasp9qFxg,6138
5
+ otcat/audit.py,sha256=m_dc-hSfftNBhx0bubRDAtHXEqmIolojEuENihGJda4,6095
6
+ otcat/client.py,sha256=EvCwzx6dddyN1OwG_2v-eoI-m8XaaQx6IhJC1H0y7dk,7341
7
+ otcat/exceptions.py,sha256=8xoy4skJV6qq2XBzDNUhBjp9dfLrsDYA69fGd6YO860,2958
8
+ otcat/models.py,sha256=7lGwkEUI8RM32IjXJt_5eHr_zwPASAv_I2JWkDkfAjs,2366
9
+ otcat/pandas_ext.py,sha256=ns0PdgWk90QTuqK2Qayb3BZkbhTBqqEr3-Xb_4I1QC4,3435
10
+ otcat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ otcat/_bin/otcat-linux-amd64,sha256=0q49tIHzby_EmTIBBA4pWKtBjIT-WgBZM3H81SM52Mk,2363652
12
+ otcat-1.0.0.dist-info/METADATA,sha256=fTo2idXb1mIHSVQngNlACP8d0AOlFpuSEVbh7e7CfCM,5462
13
+ otcat-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ otcat-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any