itf-py 0.4.3__tar.gz

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.
itf_py-0.4.3/PKG-INFO ADDED
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: itf-py
3
+ Version: 0.4.3
4
+ Summary: Python library to parse and emit Apalache/Quint traces in ITF JSON
5
+ Keywords: itf,trace,parser,tlaplus,apalache,quint
6
+ Author: Igor Konnov
7
+ Author-email: igor@konnov.phd
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: frozendict (>=2.4.6,<3.0.0)
14
+ Project-URL: Homepage, https://github.com/konnov/itf-py
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ITF-py: Parser and Encoder for the ITF Trace Format
18
+
19
+ Python library to parse and emit Apalache ITF traces. Refer to [ADR015][] for
20
+ the format. ITF traces are emitted by [Apalache][] and [Quint][].
21
+
22
+ **Intentionally minimalistic.** We keep this library intentionally minimalistic.
23
+ You can use it in your projects without worrying about pulling dozens of
24
+ dependencies. The package depends on `frozendict`.
25
+
26
+ **Why?** It's much more convenient to manipulate with trace data in an
27
+ interactive prompt, similar to SQL.
28
+
29
+ See usage examples on the [itf-py][] page on Github.
30
+
31
+ **Alternatives.** If you need to deserialize/serialize ITF traces in Rust, check
32
+ [itf-rs][].
33
+
34
+ [ADR015]: https://apalache-mc.org/docs/adr/015adr-trace.html
35
+ [Apalache]: https://github.com/apalache-mc/apalache
36
+ [Quint]: https://github.com/informalsystems/quint
37
+ [itf-rs]: https://github.com/informalsystems/itf-rs
38
+ [itf-py]: https://github.com/konnov/itf-py
itf_py-0.4.3/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # ITF-py: Parser and Encoder for the ITF Trace Format
2
+
3
+ Python library to parse and emit Apalache ITF traces. Refer to [ADR015][] for
4
+ the format. ITF traces are emitted by [Apalache][] and [Quint][].
5
+
6
+ **Intentionally minimalistic.** We keep this library intentionally minimalistic.
7
+ You can use it in your projects without worrying about pulling dozens of
8
+ dependencies. The package depends on `frozendict`.
9
+
10
+ **Why?** It's much more convenient to manipulate with trace data in an
11
+ interactive prompt, similar to SQL.
12
+
13
+ See usage examples on the [itf-py][] page on Github.
14
+
15
+ **Alternatives.** If you need to deserialize/serialize ITF traces in Rust, check
16
+ [itf-rs][].
17
+
18
+ [ADR015]: https://apalache-mc.org/docs/adr/015adr-trace.html
19
+ [Apalache]: https://github.com/apalache-mc/apalache
20
+ [Quint]: https://github.com/informalsystems/quint
21
+ [itf-rs]: https://github.com/informalsystems/itf-rs
22
+ [itf-py]: https://github.com/konnov/itf-py
@@ -0,0 +1,86 @@
1
+ [tool.poetry]
2
+ name = "itf-py"
3
+ version = "0.4.3"
4
+ description = "Python library to parse and emit Apalache/Quint traces in ITF JSON"
5
+ homepage = "https://github.com/konnov/itf-py"
6
+ authors = ["Igor Konnov <igor@konnov.phd>"]
7
+ readme = "README.md"
8
+ keywords = ["itf", "trace", "parser", "tlaplus", "apalache", "quint"]
9
+ packages = [{include = "itf_py", from = "src"}]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.12"
13
+ frozendict = "^2.4.6"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ pytest = "^8.0.0"
17
+ pytest-cov = "^5.0.0"
18
+ black = "^24.0.0"
19
+ isort = "^5.13.0"
20
+ flake8 = "^7.0.0"
21
+ mypy = "^1.8.0"
22
+ markdown-pytest = "^0.3.2"
23
+
24
+
25
+ [build-system]
26
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
27
+ build-backend = "poetry.core.masonry.api"
28
+
29
+ [tool.black]
30
+ line-length = 88
31
+ target-version = ['py312']
32
+ include = '\.pyi?$'
33
+ extend-exclude = '''
34
+ /(
35
+ # directories
36
+ \.eggs
37
+ | \.git
38
+ | \.hg
39
+ | \.mypy_cache
40
+ | \.tox
41
+ | \.venv
42
+ | build
43
+ | dist
44
+ )/
45
+ '''
46
+
47
+ [tool.isort]
48
+ profile = "black"
49
+ multi_line_output = 3
50
+ line_length = 88
51
+ known_first_party = ["itf_py"]
52
+
53
+ [tool.mypy]
54
+ python_version = "3.12"
55
+ warn_return_any = true
56
+ warn_unused_configs = true
57
+ disallow_untyped_defs = true
58
+ disallow_incomplete_defs = true
59
+ check_untyped_defs = true
60
+ disallow_untyped_decorators = true
61
+ no_implicit_optional = true
62
+ warn_redundant_casts = true
63
+ warn_unused_ignores = true
64
+ warn_no_return = true
65
+ warn_unreachable = true
66
+ strict_equality = true
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ python_files = ["test_*.py"]
71
+ python_classes = ["Test*"]
72
+ python_functions = ["test_*"]
73
+ addopts = "--strict-markers --strict-config --verbose"
74
+
75
+ [tool.flake8]
76
+ max-line-length = 88
77
+ extend-ignore = ["E203", "W503"]
78
+ exclude = [
79
+ ".git",
80
+ "__pycache__",
81
+ ".venv",
82
+ ".eggs",
83
+ "*.egg",
84
+ "dist",
85
+ "build",
86
+ ]
@@ -0,0 +1,28 @@
1
+ """
2
+ Python library to parse and emit Apalache ITF traces.
3
+ """
4
+
5
+ from .itf import (
6
+ State,
7
+ Trace,
8
+ itf_variant,
9
+ state_from_json,
10
+ state_to_json,
11
+ trace_from_json,
12
+ trace_to_json,
13
+ value_from_json,
14
+ value_to_json,
15
+ )
16
+
17
+ __version__ = "0.2.1"
18
+ __all__ = [
19
+ "State",
20
+ "Trace",
21
+ "itf_variant",
22
+ "state_from_json",
23
+ "state_to_json",
24
+ "trace_from_json",
25
+ "trace_to_json",
26
+ "value_from_json",
27
+ "value_to_json",
28
+ ]
@@ -0,0 +1,255 @@
1
+ from collections import namedtuple
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict, Iterable, List, NoReturn, Optional, SupportsIndex
4
+
5
+ from frozendict import frozendict
6
+
7
+
8
+ def itf_variant(cls): # type: ignore[no-untyped-def]
9
+ """Decorator to mark a class as an ITF variant type as opposed to a record."""
10
+
11
+ cls._itf_variant = True
12
+ return cls
13
+
14
+
15
+ @dataclass
16
+ class State:
17
+ """A single state in an ITF trace as a Python object."""
18
+
19
+ meta: Dict[str, Any]
20
+ values: Dict[str, Any]
21
+
22
+
23
+ @dataclass
24
+ class Trace:
25
+ """An ITF trace as a Python object."""
26
+
27
+ meta: Dict[str, Any]
28
+ params: List[str]
29
+ vars: List[str]
30
+ states: List[State]
31
+ loop: Optional[int]
32
+
33
+
34
+ class ImmutableList(list):
35
+ """An immutable wrapper around list that supports hashing,
36
+ yet displays as a list."""
37
+
38
+ # frozenlist.FrozenList is what we want, but it does not display
39
+ # nicely in pretty-printing.
40
+
41
+ def __init__(self, items: Iterable[Any]):
42
+ super().__init__(items)
43
+
44
+ def __hash__(self) -> int: # type: ignore
45
+ return hash(tuple(self))
46
+
47
+ def _forbid_modification(self) -> NoReturn:
48
+ """Forbid modification of the list."""
49
+ raise TypeError("This list is immutable and cannot be modified.")
50
+
51
+ def __setitem__(self, _key: Any, _value: Any) -> NoReturn:
52
+ self._forbid_modification()
53
+
54
+ def __delitem__(self, _key: Any) -> NoReturn:
55
+ self._forbid_modification()
56
+
57
+ def append(self, _value: Any) -> NoReturn:
58
+ self._forbid_modification()
59
+
60
+ def extend(self, _values: Iterable[Any]) -> NoReturn:
61
+ self._forbid_modification()
62
+
63
+ def insert(self, _index: SupportsIndex, _value: Any) -> None:
64
+ self._forbid_modification()
65
+
66
+ def pop(self, _index: SupportsIndex = -1) -> NoReturn:
67
+ self._forbid_modification()
68
+
69
+ def remove(self, _value: Any) -> NoReturn:
70
+ self._forbid_modification()
71
+
72
+ def clear(self) -> NoReturn:
73
+ self._forbid_modification()
74
+
75
+ def reverse(self) -> NoReturn:
76
+ self._forbid_modification()
77
+
78
+
79
+ class ImmutableDict(frozendict):
80
+ """A wrapper around frozendict that displays dictionaries as
81
+ `{k1: v_1, ..., k_n: v_n}`."""
82
+
83
+ def __new__(cls, items: Dict[str, Any]) -> Any:
84
+ return super().__new__(cls, items)
85
+
86
+
87
+ ImmutableDict.__str__ = ( # type: ignore
88
+ dict.__str__
89
+ ) # use the default dict representation in pretty-printing
90
+
91
+ ImmutableDict.__repr__ = ( # type: ignore
92
+ dict.__repr__
93
+ ) # use the default dict representation in pretty-printing
94
+
95
+
96
+ @dataclass
97
+ class ITFUnserializable:
98
+ """A placeholder for unserializable values."""
99
+
100
+ value: str
101
+
102
+
103
+ def value_from_json(val: Any) -> Any:
104
+ """Deserialize a Python value from JSON"""
105
+ if isinstance(val, list):
106
+ return ImmutableList([value_from_json(v) for v in val])
107
+ elif isinstance(val, dict):
108
+ if "#bigint" in val:
109
+ return int(val["#bigint"])
110
+ elif "#tup" in val:
111
+ return tuple(value_from_json(v) for v in val["#tup"])
112
+ elif "#set" in val:
113
+ return frozenset(value_from_json(v) for v in val["#set"])
114
+ elif "#map" in val:
115
+ d = {value_from_json(k): value_from_json(v) for (k, v) in val["#map"]}
116
+ return ImmutableDict(d)
117
+ elif "#unserializable" in val:
118
+ return ITFUnserializable(value=val["#unserializable"])
119
+ else:
120
+ ks = val.keys()
121
+ if len(ks) == 2 and "tag" in ks and "value" in ks:
122
+ # This is a tagged union, e.g., {"tag": "Banana", "value": {...}}.
123
+ # Produce Banana(...)
124
+ value_field = val["value"]
125
+ if isinstance(value_field, dict):
126
+ # The value is a record: {"tag": "Banana", "value": {"length": 5}}
127
+ # Decorate it with @itf_variant.
128
+ union_type_record = itf_variant(
129
+ namedtuple(val["tag"], value_field.keys())
130
+ )
131
+ return union_type_record(
132
+ **{k: value_from_json(v) for k, v in value_field.items()}
133
+ )
134
+ else:
135
+ # The value is a scalar: {"tag": "Banana", "value": "u_OF_UNIT"}
136
+ # Decorate it with @itf_variant.
137
+ union_type_scalar = itf_variant(namedtuple(val["tag"], ["value"]))
138
+ return union_type_scalar(value=value_from_json(value_field))
139
+ else:
140
+ # This is a general record, e.g., {"field1": ..., "field2": ...}.
141
+ rec_type = namedtuple("Rec", list(val.keys())) # type: ignore[misc]
142
+ return rec_type(**{k: value_from_json(v) for k, v in val.items()})
143
+ else:
144
+ return val # int, str, bool
145
+
146
+
147
+ def value_to_json(val: Any) -> Any:
148
+ """Serialize a Python value into JSON"""
149
+ if isinstance(val, bool):
150
+ return val
151
+ elif isinstance(val, str):
152
+ return val
153
+ elif isinstance(val, int):
154
+ return {"#bigint": str(val)}
155
+ elif isinstance(val, tuple) and not hasattr(val, "_fields"):
156
+ return {"#tup": [value_to_json(v) for v in val]}
157
+ elif isinstance(val, frozenset):
158
+ return {"#set": [value_to_json(v) for v in val]}
159
+ elif isinstance(val, dict):
160
+ return {"#map": [[value_to_json(k), value_to_json(v)] for k, v in val.items()]}
161
+ elif isinstance(val, list):
162
+ return [value_to_json(v) for v in val]
163
+ elif hasattr(val, "__dict__"):
164
+ # An object-like structure, e.g., a record, or a union.
165
+ # Note that we cannot distinguish between a record and a tagged union here.
166
+ if not hasattr(val.__class__, "_itf_variant"):
167
+ return {k: value_to_json(v) for k, v in val.__dict__.items()}
168
+ else:
169
+ # This is a tagged union
170
+ tag_name = val.__class__.__name__
171
+ fields_dict = val.__dict__
172
+ if len(fields_dict) == 0:
173
+ # No fields: {"tag": "Banana", "value": null}
174
+ return {
175
+ "tag": tag_name,
176
+ "value": None,
177
+ }
178
+ elif list(fields_dict.keys()) == ["value"]:
179
+ # Single field named "value": {"tag": "Banana", "value": ...}
180
+ return {
181
+ "tag": tag_name,
182
+ "value": value_to_json(fields_dict["value"]),
183
+ }
184
+ else:
185
+ # Multiple fields or a non-value field:
186
+ # {"tag": "Banana", "value": {...}}
187
+ return {
188
+ "tag": tag_name,
189
+ "value": {k: value_to_json(v) for k, v in fields_dict.items()},
190
+ }
191
+ elif isinstance(val, tuple) and hasattr(val, "_fields"):
192
+ if not hasattr(val.__class__, "_itf_variant"):
193
+ # Regular record
194
+ fields_dict = val._asdict() # type: ignore[attr-defined]
195
+ return {k: value_to_json(v) for k, v in fields_dict.items()}
196
+ else:
197
+ # This is a tagged union
198
+ tag_name = val.__class__.__name__
199
+ fields_dict = val._asdict() # type: ignore[attr-defined]
200
+ if len(fields_dict) == 0:
201
+ # No fields: {"tag": "Banana", "value": null}
202
+ return {
203
+ "tag": tag_name,
204
+ "value": None,
205
+ }
206
+ elif list(fields_dict.keys()) == ["value"]:
207
+ # Single field named "value": {"tag": "Banana", "value": ...}
208
+ return {
209
+ "tag": tag_name,
210
+ "value": value_to_json(fields_dict["value"]),
211
+ }
212
+ else:
213
+ # Multiple fields or a non-value field:
214
+ # {"tag": "Banana", "value": {...}}
215
+ return {
216
+ "tag": tag_name,
217
+ "value": {k: value_to_json(v) for k, v in fields_dict.items()},
218
+ }
219
+ else:
220
+ return ITFUnserializable(value=str(val))
221
+
222
+
223
+ def state_from_json(raw_state: Dict[str, Any]) -> State:
224
+ """Deserialize a single State from JSON"""
225
+ state_meta = raw_state["#meta"] if "#meta" in raw_state else {}
226
+ values = {k: value_from_json(v) for k, v in raw_state.items() if k != "#meta"}
227
+ return State(meta=state_meta, values=values)
228
+
229
+
230
+ def state_to_json(state: State) -> Dict[str, Any]:
231
+ """Serialize a single State to JSON"""
232
+ result = {"#meta": state.meta}
233
+ for k, v in state.values.items():
234
+ result[k] = value_to_json(v)
235
+ return result
236
+
237
+
238
+ def trace_from_json(data: Dict[str, Any]) -> Trace:
239
+ """Deserialize a Trace from JSON"""
240
+ meta = data["#meta"] if "#meta" in data else {}
241
+ params = data.get("params", [])
242
+ vars_ = data["vars"]
243
+ loop = data.get("loop", None)
244
+ states = [state_from_json(s) for s in data["states"]]
245
+ return Trace(meta=meta, params=params, vars=vars_, states=states, loop=loop)
246
+
247
+
248
+ def trace_to_json(trace: Trace) -> Dict[str, Any]:
249
+ """Serialize a Trace to JSON"""
250
+ result: Dict[str, Any] = {"#meta": trace.meta}
251
+ result["params"] = trace.params
252
+ result["vars"] = trace.vars
253
+ result["loop"] = trace.loop
254
+ result["states"] = [state_to_json(s) for s in trace.states]
255
+ return result