capkit 0.1.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.
- capkit/__init__.py +13 -0
- capkit/integration.py +22 -0
- capkit/io.py +38 -0
- capkit/model/__init__.py +6 -0
- capkit/model/frame.py +30 -0
- capkit/operations/__init__.py +2 -0
- capkit/py.typed +1 -0
- capkit/readers/__init__.py +104 -0
- capkit/readers/base.py +35 -0
- capkit/readers/kvaser_txt.py +92 -0
- capkit/writers/__init__.py +2 -0
- capkit-0.1.0.dist-info/METADATA +240 -0
- capkit-0.1.0.dist-info/RECORD +16 -0
- capkit-0.1.0.dist-info/WHEEL +4 -0
- capkit-0.1.0.dist-info/entry_points.txt +2 -0
- capkit-0.1.0.dist-info/licenses/LICENSE +21 -0
capkit/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Read CAN log files into a common stream of frame objects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from capkit.io import available_formats, probe, read
|
|
5
|
+
from capkit.model import Frame, LogMeta
|
|
6
|
+
from capkit.readers import register_reader
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
# model
|
|
10
|
+
"Frame", "LogMeta",
|
|
11
|
+
# io
|
|
12
|
+
"read", "probe", "available_formats", "register_reader",
|
|
13
|
+
]
|
capkit/integration.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Adapters exposed to dbckit through package entry points."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from capkit.model import Frame
|
|
8
|
+
from capkit.readers import create_reader
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DispatchReader:
|
|
12
|
+
"""Sniff and delegate generic file extensions such as ``.txt``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, strict: bool = False, **reader_options: object) -> None:
|
|
15
|
+
self.strict = strict
|
|
16
|
+
self.reader_options = reader_options
|
|
17
|
+
|
|
18
|
+
def read(self, path: Path) -> Iterator[Frame]:
|
|
19
|
+
"""Lazily sniff *path* and yield frames from the matching reader."""
|
|
20
|
+
options = {"strict": self.strict, **self.reader_options}
|
|
21
|
+
reader = create_reader(path, options=options, sniff_only=True)
|
|
22
|
+
yield from reader.read(path)
|
capkit/io.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Public file-reading and format-discovery operations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from capkit.model import Frame, LogMeta
|
|
8
|
+
from capkit.readers import create_reader, registered_formats
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read(
|
|
12
|
+
path: str | Path,
|
|
13
|
+
*,
|
|
14
|
+
format: str | None = None,
|
|
15
|
+
strict: bool = False,
|
|
16
|
+
**reader_options: object,
|
|
17
|
+
) -> Iterator[Frame]:
|
|
18
|
+
"""Lazily read frames from *path* using an explicit or detected format."""
|
|
19
|
+
source = Path(path)
|
|
20
|
+
|
|
21
|
+
def frames() -> Iterator[Frame]:
|
|
22
|
+
options = {"strict": strict, **reader_options}
|
|
23
|
+
reader = create_reader(source, format=format, options=options)
|
|
24
|
+
yield from reader.read(source)
|
|
25
|
+
|
|
26
|
+
return frames()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def probe(path: str | Path, *, format: str | None = None) -> LogMeta:
|
|
30
|
+
"""Return cheap, header-derived metadata for *path*."""
|
|
31
|
+
source = Path(path)
|
|
32
|
+
reader = create_reader(source, format=format)
|
|
33
|
+
return reader.probe(source)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def available_formats() -> list[str]:
|
|
37
|
+
"""Return the sorted names of registered log formats."""
|
|
38
|
+
return registered_formats()
|
capkit/model/__init__.py
ADDED
capkit/model/frame.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Common frame and log-metadata models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Frame:
|
|
10
|
+
"""A single CAN frame read from a log file."""
|
|
11
|
+
|
|
12
|
+
timestamp: float
|
|
13
|
+
arbitration_id: int
|
|
14
|
+
data: bytes
|
|
15
|
+
channel: int | None = None
|
|
16
|
+
is_extended_frame: bool = False
|
|
17
|
+
is_fd: bool = False
|
|
18
|
+
is_remote_frame: bool = False
|
|
19
|
+
is_error_frame: bool = False
|
|
20
|
+
is_rx: bool | None = None
|
|
21
|
+
dlc: int | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class LogMeta:
|
|
26
|
+
"""Cheap, header-derived metadata about a log file."""
|
|
27
|
+
|
|
28
|
+
format: str
|
|
29
|
+
start_time: datetime | None = None
|
|
30
|
+
extra: dict[str, str] = field(default_factory=dict)
|
capkit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Reader registry and format-resolution logic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from capkit.readers.base import Reader, read_sample
|
|
8
|
+
from capkit.readers.kvaser_txt import KvaserTxtReader
|
|
9
|
+
|
|
10
|
+
_READERS: dict[str, type[Reader]] = {
|
|
11
|
+
KvaserTxtReader.name: cast(type[Reader], KvaserTxtReader),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_reader(reader_type: type[Reader]) -> None:
|
|
16
|
+
"""Register a zero-argument reader class for process-wide format resolution."""
|
|
17
|
+
if not isinstance(reader_type, type):
|
|
18
|
+
raise TypeError("register_reader() requires a reader class, not an instance.")
|
|
19
|
+
|
|
20
|
+
name = getattr(reader_type, "name", None)
|
|
21
|
+
if not isinstance(name, str) or not name.strip():
|
|
22
|
+
raise TypeError("Reader type must define a non-empty string 'name'.")
|
|
23
|
+
|
|
24
|
+
normalized = name.strip().lower()
|
|
25
|
+
if normalized in _READERS:
|
|
26
|
+
raise ValueError(f"Log format '{normalized}' is already registered.")
|
|
27
|
+
|
|
28
|
+
extensions = getattr(reader_type, "extensions", None)
|
|
29
|
+
if (
|
|
30
|
+
not isinstance(extensions, tuple)
|
|
31
|
+
or not extensions
|
|
32
|
+
or any(not isinstance(extension, str) or not extension.startswith(".") for extension in extensions)
|
|
33
|
+
):
|
|
34
|
+
raise TypeError(
|
|
35
|
+
f"Reader type '{normalized}' must define a non-empty tuple of dot-prefixed extensions."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
reader = reader_type()
|
|
40
|
+
except Exception as error:
|
|
41
|
+
raise TypeError(f"Reader type '{normalized}' must be zero-argument constructible.") from error
|
|
42
|
+
|
|
43
|
+
if any(not callable(getattr(reader, method, None)) for method in ("sniff", "probe", "read")):
|
|
44
|
+
raise TypeError(f"Reader type '{normalized}' must define callable sniff(), probe(), and read() methods.")
|
|
45
|
+
|
|
46
|
+
_READERS[normalized] = reader_type
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def registered_formats() -> list[str]:
|
|
50
|
+
"""Return sorted registered reader names."""
|
|
51
|
+
return sorted(_READERS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _available_label() -> str:
|
|
55
|
+
return ", ".join(registered_formats())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reader_type_for(
|
|
59
|
+
path: Path,
|
|
60
|
+
*,
|
|
61
|
+
format: str | None = None,
|
|
62
|
+
sniff_only: bool = False,
|
|
63
|
+
) -> type[Reader]:
|
|
64
|
+
if not _READERS:
|
|
65
|
+
raise ValueError("No log formats are registered.")
|
|
66
|
+
|
|
67
|
+
if format is not None:
|
|
68
|
+
normalized = format.strip().lower()
|
|
69
|
+
reader_type = _READERS.get(normalized)
|
|
70
|
+
if reader_type is None:
|
|
71
|
+
raise ValueError(f"Unknown log format '{format}'. Available formats: {_available_label()}.")
|
|
72
|
+
return reader_type
|
|
73
|
+
|
|
74
|
+
if not sniff_only:
|
|
75
|
+
extension = path.suffix.lower()
|
|
76
|
+
candidates = [
|
|
77
|
+
reader_type
|
|
78
|
+
for reader_type in _READERS.values()
|
|
79
|
+
if extension and extension in (claimed.lower() for claimed in reader_type.extensions)
|
|
80
|
+
]
|
|
81
|
+
if len(candidates) == 1:
|
|
82
|
+
return candidates[0]
|
|
83
|
+
|
|
84
|
+
sample = read_sample(path)
|
|
85
|
+
matches = [reader_type for reader_type in _READERS.values() if reader_type().sniff(sample)]
|
|
86
|
+
if len(matches) == 1:
|
|
87
|
+
return matches[0]
|
|
88
|
+
|
|
89
|
+
raise ValueError(f"Unknown log format for '{path.name}'. Available formats: {_available_label()}.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_reader(
|
|
93
|
+
path: Path,
|
|
94
|
+
*,
|
|
95
|
+
format: str | None = None,
|
|
96
|
+
options: dict[str, object] | None = None,
|
|
97
|
+
sniff_only: bool = False,
|
|
98
|
+
) -> Reader:
|
|
99
|
+
"""Resolve and construct a reader for *path*."""
|
|
100
|
+
reader_type = _reader_type_for(path, format=format, sniff_only=sniff_only)
|
|
101
|
+
return cast(Reader, reader_type(**(options or {})))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["KvaserTxtReader", "Reader", "register_reader"]
|
capkit/readers/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared reader protocol and bounded file-sampling helper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from capkit.model import Frame, LogMeta
|
|
9
|
+
|
|
10
|
+
SNIFF_SIZE = 4096
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Reader(Protocol):
|
|
14
|
+
"""A stateless log-format reader."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
extensions: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
def sniff(self, sample: str) -> bool:
|
|
20
|
+
"""Return whether *sample* looks like this reader's format."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def probe(self, path: Path) -> LogMeta:
|
|
24
|
+
"""Return cheap metadata from *path*."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def read(self, path: Path) -> Iterator[Frame]:
|
|
28
|
+
"""Lazily yield frames from *path*."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_sample(path: Path) -> str:
|
|
33
|
+
"""Read and Latin-1 decode at most the first 4 KiB of *path*."""
|
|
34
|
+
with path.open("rb") as stream:
|
|
35
|
+
return stream.read(SNIFF_SIZE).decode("latin-1")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Reader for Kvaser CanKing text exports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from re import Match
|
|
8
|
+
|
|
9
|
+
from capkit.model import Frame, LogMeta
|
|
10
|
+
from capkit.readers.base import read_sample
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KvaserTxtReader:
|
|
14
|
+
"""Read the whitespace-delimited Kvaser CanKing TXT dialect."""
|
|
15
|
+
|
|
16
|
+
name = "kvaser-txt"
|
|
17
|
+
extensions = (".txt",)
|
|
18
|
+
|
|
19
|
+
_FRAME_RE = re.compile(
|
|
20
|
+
r"^\s*"
|
|
21
|
+
r"(?P<channel>\d+)"
|
|
22
|
+
r"\s+(?P<identifier>[0-9A-Fa-f]+)"
|
|
23
|
+
r"(?:\s+(?P<flag>[xX]))?"
|
|
24
|
+
r"\s+(?P<dlc>\d+)"
|
|
25
|
+
r"(?P<data>(?:\s+[0-9A-Fa-f]{2})*)"
|
|
26
|
+
r"\s+(?P<timestamp>\d+(?:\.\d+)?)"
|
|
27
|
+
r"\s+(?P<direction>[RrTt])"
|
|
28
|
+
r"\s*$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, strict: bool = False) -> None:
|
|
32
|
+
self.strict = strict
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _is_header(line: str) -> bool:
|
|
36
|
+
normalized = " ".join(line.lower().split())
|
|
37
|
+
return normalized.startswith("chn identifier flg dlc") and "time" in normalized and "dir" in normalized
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _data_tokens(match: Match[str]) -> list[str]:
|
|
41
|
+
return match.group("data").split()
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _is_complete_frame(cls, match: Match[str]) -> bool:
|
|
45
|
+
return len(cls._data_tokens(match)) == int(match.group("dlc"))
|
|
46
|
+
|
|
47
|
+
def sniff(self, sample: str) -> bool:
|
|
48
|
+
"""Recognize the Kvaser table header or a complete frame line."""
|
|
49
|
+
for line in sample.splitlines():
|
|
50
|
+
if self._is_header(line):
|
|
51
|
+
return True
|
|
52
|
+
match = self._FRAME_RE.fullmatch(line)
|
|
53
|
+
if match is not None and self._is_complete_frame(match):
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def probe(self, path: Path) -> LogMeta:
|
|
58
|
+
"""Return Kvaser metadata without scanning the frame body."""
|
|
59
|
+
if not self.sniff(read_sample(path)):
|
|
60
|
+
raise ValueError(f"File '{path.name}' is not a Kvaser TXT log.")
|
|
61
|
+
return LogMeta(format=self.name)
|
|
62
|
+
|
|
63
|
+
def read(self, path: Path) -> Iterator[Frame]:
|
|
64
|
+
"""Lazily parse Kvaser frame records from *path*."""
|
|
65
|
+
with path.open(encoding="latin-1") as stream:
|
|
66
|
+
for line_number, raw_line in enumerate(stream, start=1):
|
|
67
|
+
line = raw_line.rstrip("\r\n")
|
|
68
|
+
if not line.strip():
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
match = self._FRAME_RE.fullmatch(line)
|
|
72
|
+
if match is None:
|
|
73
|
+
if self.strict:
|
|
74
|
+
raise ValueError(f"Unrecognized Kvaser TXT line {line_number}: {line!r}.")
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
data_tokens = self._data_tokens(match)
|
|
78
|
+
dlc = int(match.group("dlc"))
|
|
79
|
+
if len(data_tokens) != dlc:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Invalid Kvaser TXT frame at line {line_number}: "
|
|
82
|
+
f"DLC {dlc} declares {dlc} data bytes, got {len(data_tokens)}."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
yield Frame(
|
|
86
|
+
timestamp=float(match.group("timestamp")),
|
|
87
|
+
arbitration_id=int(match.group("identifier"), 16),
|
|
88
|
+
data=bytes.fromhex(" ".join(data_tokens)),
|
|
89
|
+
channel=int(match.group("channel")),
|
|
90
|
+
is_extended_frame=match.group("flag") is not None,
|
|
91
|
+
is_rx=match.group("direction").upper() == "R",
|
|
92
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read CAN bus capture logs in different formats into one common frame stream
|
|
5
|
+
Project-URL: Homepage, https://canforge.io/capkit
|
|
6
|
+
Project-URL: Repository, https://github.com/canforge/capkit
|
|
7
|
+
Project-URL: Changelog, https://github.com/canforge/capkit/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: André Delgado <andre@adelgado.io>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 André Delgado
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: CAN,automotive,canbus,kvaser,log
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
40
|
+
Requires-Python: >=3.11
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: build; extra == 'dev'
|
|
43
|
+
Requires-Dist: dbckit>=1.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# capkit
|
|
51
|
+
|
|
52
|
+
[](https://pypi.org/project/capkit/)
|
|
53
|
+
[](https://github.com/canforge/capkit/actions/workflows/ci.yml)
|
|
54
|
+
[](https://pypi.org/project/capkit/)
|
|
55
|
+
[](LICENSE)
|
|
56
|
+
|
|
57
|
+
`capkit` is a Python library that reads **CAN bus capture logs into one common
|
|
58
|
+
frame stream**. Every supported format parses into the same frozen `Frame`
|
|
59
|
+
dataclass, so code that consumes frames never depends on which tool captured
|
|
60
|
+
the log.
|
|
61
|
+
|
|
62
|
+
Use it to:
|
|
63
|
+
|
|
64
|
+
- read captures from different tools as one lazy stream of typed `Frame` objects
|
|
65
|
+
- probe a file for header metadata without scanning the frame body
|
|
66
|
+
- detect the log format from the file extension or the file content
|
|
67
|
+
- skip real-world log noise by default, or reject it with `strict=True`
|
|
68
|
+
- feed frames into [dbckit](https://github.com/canforge/dbckit) for DBC signal decoding
|
|
69
|
+
|
|
70
|
+
| Format | Reader name | Extensions | Status | Dependency |
|
|
71
|
+
|---|---|---|---|---|
|
|
72
|
+
| Kvaser CanKing TXT | `kvaser-txt` | `.txt` | Supported | none |
|
|
73
|
+
| candump text | `candump` | `.log` | Planned | none |
|
|
74
|
+
| Vector ASC | `vector-asc` | `.asc` | Planned | none |
|
|
75
|
+
| PCAN TRC | `pcan-trc` | `.trc` | Planned | none |
|
|
76
|
+
| Generic CSV | `csv-table` | `.csv` | Planned | none |
|
|
77
|
+
| Vector BLF | `vector-blf` | `.blf` | Planned adapter | `python-can` |
|
|
78
|
+
| ASAM MF4 | `asam-mf4` | `.mf4` | Planned adapter | `asammdf` |
|
|
79
|
+
|
|
80
|
+
See [format support](docs/format-support.md) for the exact dialect each reader
|
|
81
|
+
accepts, and the [roadmap](ROADMAP.md) for sequencing.
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install capkit
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires Python `>=3.11`. capkit has no runtime dependencies.
|
|
90
|
+
|
|
91
|
+
## Design
|
|
92
|
+
|
|
93
|
+
- `Frame` and `LogMeta` are frozen, slotted dataclasses.
|
|
94
|
+
- `read()` is lazy and keeps constant parser state, so file size does not matter.
|
|
95
|
+
- Timestamps are returned exactly as recorded in the source, never rebased or
|
|
96
|
+
converted to absolute time.
|
|
97
|
+
- A format is added only when a real captured fixture pins its dialect under
|
|
98
|
+
`tests/fixtures/`; unsupported dialects fail clearly instead of parsing
|
|
99
|
+
approximately.
|
|
100
|
+
|
|
101
|
+
## Quick Start
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import capkit
|
|
105
|
+
|
|
106
|
+
# stream frames
|
|
107
|
+
for frame in capkit.read("trace.txt"):
|
|
108
|
+
print(frame.timestamp, hex(frame.arbitration_id), frame.data.hex())
|
|
109
|
+
|
|
110
|
+
# header metadata only
|
|
111
|
+
meta = capkit.probe("trace.txt")
|
|
112
|
+
print(meta.format, meta.start_time)
|
|
113
|
+
|
|
114
|
+
# registered reader names
|
|
115
|
+
print(capkit.available_formats()) # ['kvaser-txt']
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The public API is six names: `read`, `probe`, `available_formats`,
|
|
119
|
+
`register_reader`, `Frame`, and `LogMeta`.
|
|
120
|
+
|
|
121
|
+
## Features
|
|
122
|
+
|
|
123
|
+
### Format detection
|
|
124
|
+
|
|
125
|
+
An explicit `format=` names a reader and takes precedence over the file
|
|
126
|
+
extension:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
frames = capkit.read("capture.bin", format="kvaser-txt")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Without `format=`, capkit matches the extension against registered readers and
|
|
133
|
+
sniffs the first 4 KiB when the extension is unknown or ambiguous.
|
|
134
|
+
|
|
135
|
+
### Add your own reader
|
|
136
|
+
|
|
137
|
+
Register a zero-argument reader class to make it available to `read()`,
|
|
138
|
+
`probe()`, and format detection:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from collections.abc import Iterator
|
|
142
|
+
from pathlib import Path
|
|
143
|
+
|
|
144
|
+
import capkit
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MyReader:
|
|
148
|
+
name: str = "my-format"
|
|
149
|
+
extensions: tuple[str, ...] = (".mylog",)
|
|
150
|
+
|
|
151
|
+
def __init__(self, *, strict: bool = False) -> None:
|
|
152
|
+
self.strict = strict
|
|
153
|
+
|
|
154
|
+
def sniff(self, sample: str) -> bool:
|
|
155
|
+
return sample.startswith("MYLOG")
|
|
156
|
+
|
|
157
|
+
def probe(self, path: Path) -> capkit.LogMeta:
|
|
158
|
+
return capkit.LogMeta(format=self.name)
|
|
159
|
+
|
|
160
|
+
def read(self, path: Path) -> Iterator[capkit.Frame]:
|
|
161
|
+
# Parse path lazily and yield capkit.Frame objects here.
|
|
162
|
+
yield from ()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
capkit.register_reader(MyReader)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Registration is process-global and is normally performed at import time.
|
|
169
|
+
dbckit's `.txt` entry point sniffs among all registered readers, so a reader
|
|
170
|
+
whose `sniff()` uniquely matches the content of a `.txt` log is used there
|
|
171
|
+
too, regardless of the extensions it claims.
|
|
172
|
+
|
|
173
|
+
### Dirty logs and strict mode
|
|
174
|
+
|
|
175
|
+
Readers skip headers, trailers, comments, blank lines, and unrelated noise by
|
|
176
|
+
default. Pass `strict=True` to raise a line-numbered `ValueError` on the first
|
|
177
|
+
unrecognized nonblank line instead:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
frames = capkit.read("trace.txt", strict=True)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A frame record whose DLC disagrees with its data bytes raises in both modes;
|
|
184
|
+
corrupt frames are never silently dropped.
|
|
185
|
+
|
|
186
|
+
## Use with dbckit
|
|
187
|
+
|
|
188
|
+
[dbckit](https://github.com/canforge/dbckit) decodes CAN frames against a DBC
|
|
189
|
+
database. capkit and dbckit are separate packages — neither depends on or
|
|
190
|
+
imports the other — with adjacent jobs: capkit turns bytes on disk into frames,
|
|
191
|
+
dbckit turns frames plus a DBC into signals.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import capkit
|
|
195
|
+
import dbckit
|
|
196
|
+
|
|
197
|
+
db = dbckit.load("truck.dbc")
|
|
198
|
+
for decoded in dbckit.decode_frames(db, capkit.read("trace.txt")):
|
|
199
|
+
print(decoded.timestamp, decoded.signals)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
capkit also registers its readers in dbckit's `dbckit.readers` entry-point
|
|
203
|
+
group. With both packages installed, `dbckit.decode_log()` reads `.txt` logs
|
|
204
|
+
through capkit directly:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
for decoded in dbckit.decode_log(db, "trace.txt"):
|
|
208
|
+
print(decoded.signals)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Scope and Caveats
|
|
212
|
+
|
|
213
|
+
- Kvaser dialects with absolute start-time headers are not supported;
|
|
214
|
+
`probe()` returns `start_time=None` for `kvaser-txt`.
|
|
215
|
+
- capkit reads frames only: no DBC or signal awareness (that is dbckit's job),
|
|
216
|
+
no hardware I/O, no log writing, no dataframe export, no CLI.
|
|
217
|
+
|
|
218
|
+
## Documentation
|
|
219
|
+
|
|
220
|
+
- [Format support](docs/format-support.md) — supported formats and the exact
|
|
221
|
+
dialect each reader accepts
|
|
222
|
+
- [API reference](docs/api-reference.md) — the public API contract
|
|
223
|
+
- [Recipes](docs/recipes.md) — counting IDs, filtering, time windows, CSV
|
|
224
|
+
export, and dataframes in a few lines of standard library
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
python -m venv .venv
|
|
230
|
+
source .venv/bin/activate
|
|
231
|
+
pip install -e ".[dev]"
|
|
232
|
+
pytest
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The `dev` extra includes dbckit so the entry-point integration tests run; the
|
|
236
|
+
core and contract suites pass without it.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
capkit/__init__.py,sha256=l6KOaVUfBHbeibn7CX1xACeT-uxq9BEfSzPIQQR-LGU,357
|
|
2
|
+
capkit/integration.py,sha256=Yc7BNz-eByJh9wMQ08SkgAgwzhux5ZLCyDqxo3Zmno8,789
|
|
3
|
+
capkit/io.py,sha256=2gLk-zCjlW9zpmZLL1X4y4jttJh0npkjajOhROoavS4,1107
|
|
4
|
+
capkit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
5
|
+
capkit/model/__init__.py,sha256=Rg3bkQjSPsGIsXoZAfMiSeeeiPO6S6jvMoFr_InTxQw,147
|
|
6
|
+
capkit/model/frame.py,sha256=XQyqVHuSAh_fI3zjLZJEkqaFauj8cb8tBvdpbwYdzSk,754
|
|
7
|
+
capkit/operations/__init__.py,sha256=ksgo7TaQtvU9fvGGrrfAyYS4o-axtDbkPGGZ9I_dwfs,90
|
|
8
|
+
capkit/readers/__init__.py,sha256=3BQ9ZT8-xwRNNO3fNZ-GYijlnjg1q69Qbp9Ma7bC4Ro,3475
|
|
9
|
+
capkit/readers/base.py,sha256=YZD1kKgRP6l06u2zhAvYSaRD-Y3E3emjyTv6nBBi74Q,906
|
|
10
|
+
capkit/readers/kvaser_txt.py,sha256=PmOsHW4mikQ6T9ZOTS--nHut7VxEpszmRpg2u2w84KA,3386
|
|
11
|
+
capkit/writers/__init__.py,sha256=9XwmWR0Y4bp3KnIh0MMjQk1cHZjX_bX6-su3Ho8hWzw,84
|
|
12
|
+
capkit-0.1.0.dist-info/METADATA,sha256=6UUFM4aAKMm7XLO_svalqC44L3s5wmEF37XNQobFtRA,8470
|
|
13
|
+
capkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
capkit-0.1.0.dist-info/entry_points.txt,sha256=5NWQwqFV5V6S-qnkTgr2o8lFoLu87Z9HN36gevqDNmQ,57
|
|
15
|
+
capkit-0.1.0.dist-info/licenses/LICENSE,sha256=VArw2mCCiRiU3jUc906yLPYKJCOtrNFyAINYPHzO-1g,1071
|
|
16
|
+
capkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 André Delgado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|