expr_tracker 0.4.0__tar.gz → 0.4.1__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.
Files changed (136) hide show
  1. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/PKG-INFO +3 -2
  2. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/README.md +2 -1
  3. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/architecture.md +16 -0
  4. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/configuration.md +17 -0
  5. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/reader.py +45 -3
  6. expr_tracker-0.4.1/src/expr_tracker/history/rotation.py +29 -0
  7. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/store.py +11 -3
  8. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/writer.py +90 -26
  9. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/trace.py +2 -1
  10. expr_tracker-0.4.1/tests/test_rotation.py +217 -0
  11. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/ci.yaml +0 -0
  12. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/docs.yaml +0 -0
  13. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.github/workflows/release.yaml +0 -0
  14. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/.gitignore +0 -0
  15. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/LICENSE +0 -0
  16. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/design.md +0 -0
  17. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/examples.md +0 -0
  18. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/getting-started.md +0 -0
  19. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/alerts.md +0 -0
  20. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/artifacts.md +0 -0
  21. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/backends.md +0 -0
  22. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/cli.md +0 -0
  23. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/distributed.md +0 -0
  24. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/history.md +0 -0
  25. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/logging.md +0 -0
  26. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/spans.md +0 -0
  27. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/guide/streams.md +0 -0
  28. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/index.md +0 -0
  29. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/api.md +0 -0
  30. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/docs/reference/expressions.md +0 -0
  31. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/README.md +0 -0
  32. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/alert_rules.py +0 -0
  33. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/checkpoints.py +0 -0
  34. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/early_stopping.py +0 -0
  35. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/multiprocess_pipeline.py +0 -0
  36. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/profile_step.py +0 -0
  37. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/examples/quickstart.py +0 -0
  38. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/mkdocs.yml +0 -0
  39. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/pyproject.toml +0 -0
  40. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/__init__.py +0 -0
  41. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/_compat.py +0 -0
  42. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/_process.py +0 -0
  43. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/__init__.py +0 -0
  44. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  45. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/base.py +0 -0
  46. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/backends/cards.py +0 -0
  47. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/dispatch.py +0 -0
  48. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/engine.py +0 -0
  49. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  50. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/eval.py +0 -0
  51. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/functions.py +0 -0
  52. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  53. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  54. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/parser.py +0 -0
  55. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/expr/rule.py +0 -0
  56. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/alerts/models.py +0 -0
  57. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/artifacts.py +0 -0
  58. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/cli.py +0 -0
  59. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/encoders.py +0 -0
  60. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/__init__.py +0 -0
  61. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/codec.py +0 -0
  62. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/frame.py +0 -0
  63. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/naming.py +0 -0
  64. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/history/series.py +0 -0
  65. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/plugins.py +0 -0
  66. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/py.typed +0 -0
  67. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/run.py +0 -0
  68. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/spans.py +0 -0
  69. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/summary.py +0 -0
  70. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/tracker.py +0 -0
  71. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/src/expr_tracker/types.py +0 -0
  72. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/conftest.py +0 -0
  73. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_backends.py +0 -0
  74. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_delivery.py +0 -0
  75. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_dispatch.py +0 -0
  76. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_engine.py +0 -0
  77. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_models.py +0 -0
  78. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_alert_routing.py +0 -0
  79. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_artifacts.py +0 -0
  80. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_benchmark.py +0 -0
  81. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_cache.py +0 -0
  82. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_callback_isolation.py +0 -0
  83. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_cli.py +0 -0
  84. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_commit_timestamps.py +0 -0
  85. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_correctness.py +0 -0
  86. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_distributed.py +0 -0
  87. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_e2e.py +0 -0
  88. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_examples.py +0 -0
  89. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_builder.py +0 -0
  90. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_eval.py +0 -0
  91. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_functions.py +0 -0
  92. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_parser.py +0 -0
  93. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_expr_properties.py +0 -0
  94. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_failure_modes.py +0 -0
  95. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_features.py +0 -0
  96. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_fork_safety.py +0 -0
  97. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_frame_codec_summary.py +0 -0
  98. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_history.py +0 -0
  99. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_history_admission.py +0 -0
  100. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_hot_paths.py +0 -0
  101. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_integration.py +0 -0
  102. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_lark.py +0 -0
  103. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_nonfinite_conditions.py +0 -0
  104. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_perf.py +0 -0
  105. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_process_compat.py +0 -0
  106. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_public_surfaces.py +0 -0
  107. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_reader_edge_cases.py +0 -0
  108. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_review_regressions.py +0 -0
  109. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_lifecycle.py +0 -0
  110. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_overrides.py +0 -0
  111. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_rule_quoted_names.py +0 -0
  112. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_run_backends.py +0 -0
  113. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_run_dir.py +0 -0
  114. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_scenarios.py +0 -0
  115. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_serialization_edge_cases.py +0 -0
  116. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_series_edge_cases.py +0 -0
  117. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_lifecycle.py +0 -0
  118. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_plugins.py +0 -0
  119. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_span_value_encoding.py +0 -0
  120. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_spans.py +0 -0
  121. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_step_commit.py +0 -0
  122. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_step_types.py +0 -0
  123. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_streams.py +0 -0
  124. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_stress.py +0 -0
  125. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trace.py +0 -0
  126. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trace_perf.py +0 -0
  127. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_trackio.py +0 -0
  128. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_unicode_records.py +0 -0
  129. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_value_encoding.py +0 -0
  130. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_wandb.py +0 -0
  131. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_async.py +0 -0
  132. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_buffer.py +0 -0
  133. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_bytes.py +0 -0
  134. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_diagnostics.py +0 -0
  135. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/tests/test_writer_durability.py +0 -0
  136. {expr_tracker-0.4.0 → expr_tracker-0.4.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
5
5
  Project-URL: Homepage, https://hspk.github.io/expr_tracker/
6
6
  Project-URL: Documentation, https://hspk.github.io/expr_tracker/
@@ -76,7 +76,8 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
76
76
  ## Why
77
77
 
78
78
  - **The file is the source of truth.** One JSON object per step, appended to
79
- `metrics.jsonl`. No server, no database, no vendor.
79
+ `metrics.jsonl`, automatically split into retained segments at 100 MiB.
80
+ History queries read all segments transparently. No server, no database, no vendor.
80
81
  - **History is queryable during the run.** `et.history(n)` answers from an in-memory
81
82
  cache and touches the file only for what it evicted — 227 µs for
82
83
  `history(50)` whether the run has 1,000 steps or 100,000.
@@ -26,7 +26,8 @@ et.history(-1, output_type="pd") # everything, as a DataFrame
26
26
  ## Why
27
27
 
28
28
  - **The file is the source of truth.** One JSON object per step, appended to
29
- `metrics.jsonl`. No server, no database, no vendor.
29
+ `metrics.jsonl`, automatically split into retained segments at 100 MiB.
30
+ History queries read all segments transparently. No server, no database, no vendor.
30
31
  - **History is queryable during the run.** `et.history(n)` answers from an in-memory
31
32
  cache and touches the file only for what it evicted — 227 µs for
32
33
  `history(50)` whether the run has 1,000 steps or 100,000.
@@ -123,6 +123,22 @@ plain functions over a sequence, so they hold no lock and are tested directly.
123
123
  `et.history(run=...)` bypasses the store entirely and reads through `JsonlReader`
124
124
  (`read_history`), which is what makes offline analysis and `et rules test` possible.
125
125
 
126
+ ### Rotation segments
127
+
128
+ Metric and span writers default to `max_file_bytes=100 << 20`; `None` disables
129
+ rotation. The original file is never renamed. Additional, permanently retained
130
+ segments live in `<filename>.parts/00000001.jsonl`, incrementing numerically.
131
+ Writes split batches only at record boundaries, and oversized records stay intact.
132
+ On failure, rollback truncates the logical byte stream to its pre-batch size and
133
+ removes newly written suffix segments before retrying.
134
+
135
+ The sparse index and metadata use cumulative byte offsets and row counts across
136
+ all segments. Readers capture the segment list and each file's byte boundary per
137
+ query, then scan or reverse-tail them as one logical file. This preserves cache
138
+ boundaries, cross-segment patch merging, and snapshots while new segments appear.
139
+ Resume repairs the active segment's torn tail and restores the whole run's state.
140
+ Span trace export uses the same segmented raw reader.
141
+
126
142
  ### Rows versus steps
127
143
 
128
144
  A step normally occupies one physical row, but an explicit commit followed by
@@ -27,6 +27,7 @@ list of valid options rather than being silently ignored.
27
27
  | `max_pending_records` | 100,000 | cap on queued plus in-flight records |
28
28
  | `max_pending_bytes` | 64 MiB | cap on queued plus in-flight encoded bytes; `None` disables |
29
29
  | `max_record_bytes` | 8 MiB | maximum encoded size of one record; `None` disables |
30
+ | `max_file_bytes` | 100 MiB | rotate metric and span files at record boundaries; `None` disables |
30
31
 
31
32
  ```python
32
33
  et.init(project="p", cache_bytes=256 << 20, step_policy="allow")
@@ -68,6 +69,22 @@ wait for accepted records to be flushed in either mode.
68
69
  `et.info()["history"]` reports metric/span `pending_bytes`, `span_pending_bytes`,
69
70
  `dropped_records` and `dropped_spans` for diagnosing overload and failed writes.
70
71
 
72
+ ### Automatic rotation
73
+
74
+ Metrics and spans rotate by default before the next record would make a file
75
+ exceed 100 MiB. Change the threshold with `et.init(max_file_bytes=20 << 20)`, or
76
+ disable rotation with `et.init(max_file_bytes=None)`. The limit must be a positive
77
+ integer or `None`. A record larger than the threshold stays intact in its own file
78
+ (subject to the independent `max_record_bytes` limit).
79
+
80
+ The original filename remains in place. Subsequent segments live in its `.parts`
81
+ directory, for example `metrics.jsonl.parts/00000001.jsonl`. Stream and rank files
82
+ rotate independently. All segments are retained: rotation limits individual file
83
+ size, not total disk usage. Keep the `.parts` directory alongside the original file
84
+ when copying a run. Live and offline history queries, resume, and span trace export
85
+ read the complete logical history across segments, even when rotation is later
86
+ disabled. Segment files are implementation details, not separate streams.
87
+
71
88
  ## Alert configuration
72
89
 
73
90
  Resolved in this order:
@@ -16,12 +16,13 @@ from loguru import logger
16
16
 
17
17
  from .frame import project, to_output
18
18
  from .naming import RANK_PATTERN, metrics_filename
19
+ from .rotation import segments
19
20
 
20
21
  CHUNK_SIZE = 256 * 1024
21
22
 
22
23
 
23
24
  class JsonlReader:
24
- """Read-only access to one ``metrics.jsonl``.
25
+ """Read-only access to a JSONL file and all its rotation segments.
25
26
 
26
27
  ``index`` is a sparse list of ``(step, line, offset)`` anchors, loaded from the
27
28
  meta sidecar or rebuilt by scanning. With ``merge=True`` results are merged by
@@ -51,6 +52,7 @@ class JsonlReader:
51
52
  self._snapshot_limit: int | None = None
52
53
  self._snapshot_file_size: int | None = None
53
54
  self._cached_snapshot: tuple[int, JsonlReader] | None = None
55
+ self._segments: list[tuple[Path, int]] | None = None
54
56
 
55
57
  def _snapshot(self, end: int | None = None) -> JsonlReader:
56
58
  """A query-local reader whose metadata and data share one byte boundary."""
@@ -58,13 +60,25 @@ class JsonlReader:
58
60
  end is None or end >= self._snapshot_limit
59
61
  ):
60
62
  return self
61
- size = self.size
63
+ captured = self._segments
64
+ if captured is None:
65
+ captured = [
66
+ (path, path.stat().st_size)
67
+ for path in segments(self.path)
68
+ if path.exists()
69
+ ]
70
+ size = (
71
+ self.size
72
+ if self._snapshot_limit is not None
73
+ else sum(length for _, length in captured)
74
+ )
62
75
  limit = size if end is None else max(0, min(end, size))
63
76
  source = self
64
77
  cached = self._cached_snapshot
65
78
  if cached is not None and cached[0] >= limit:
66
79
  source = cached[1]
67
80
  reader = JsonlReader(self.path)
81
+ reader._segments = captured
68
82
  reader._snapshot_limit = limit
69
83
  reader._snapshot_file_size = (
70
84
  size if self._snapshot_file_size is None else self._snapshot_file_size
@@ -83,7 +97,7 @@ class JsonlReader:
83
97
  def size(self) -> int:
84
98
  if self._snapshot_limit is not None:
85
99
  return self._snapshot_limit
86
- return self.path.stat().st_size if self.path.exists() else 0
100
+ return sum(path.stat().st_size for path in segments(self.path) if path.exists())
87
101
 
88
102
  @property
89
103
  def merge(self) -> bool:
@@ -170,6 +184,23 @@ class JsonlReader:
170
184
 
171
185
  def _scan(self, start: int = 0, end: int | None = None):
172
186
  """Yield ``(byte offset, raw line)`` for every line, blanks included."""
187
+ if self._segments is not None:
188
+ limit = self.size if end is None else min(end, self.size)
189
+ base = 0
190
+ for path, length in self._segments:
191
+ if base >= limit:
192
+ break
193
+ if base + length > start:
194
+ reader = JsonlReader(path)
195
+ reader._snapshot_limit = min(length, limit - base)
196
+ reader._snapshot_file_size = length
197
+ for offset, raw in reader._scan_file(max(0, start - base)):
198
+ yield base + offset, raw
199
+ base += length
200
+ return
201
+ yield from self._scan_file(start, end)
202
+
203
+ def _scan_file(self, start: int = 0, end: int | None = None):
173
204
  if not self.path.exists():
174
205
  return
175
206
  limit = self.size if end is None else end
@@ -207,6 +238,17 @@ class JsonlReader:
207
238
 
208
239
  def _reverse_raw(self, end: int | None = None) -> Iterator[bytes]:
209
240
  """Yield nonblank lines backwards, joining long lines only once."""
241
+ if self._segments is not None:
242
+ limit = self.size if end is None else min(end, self.size)
243
+ base = sum(length for _, length in self._segments)
244
+ for path, length in reversed(self._segments):
245
+ base -= length
246
+ if base < limit:
247
+ reader = JsonlReader(path)
248
+ reader._snapshot_limit = min(length, limit - base)
249
+ reader._snapshot_file_size = length
250
+ yield from reader._reverse_raw()
251
+ return
210
252
  if not self.path.exists():
211
253
  return
212
254
  size = self.size
@@ -0,0 +1,29 @@
1
+ """Stable JSONL segments, addressed as one append-only byte stream."""
2
+
3
+ from pathlib import Path
4
+
5
+ DEFAULT_MAX_FILE_BYTES = 100 * 1024 * 1024
6
+
7
+
8
+ def validate_file_limit(max_file_bytes: int | None) -> None:
9
+ if max_file_bytes is not None and (
10
+ type(max_file_bytes) is not int or max_file_bytes <= 0
11
+ ):
12
+ raise ValueError("max_file_bytes must be a positive integer or None")
13
+
14
+
15
+ def segment_path(path: Path, number: int) -> Path:
16
+ return path.with_name(path.name + ".parts") / f"{number:08d}.jsonl"
17
+
18
+
19
+ def segments(path: Path) -> list[Path]:
20
+ """The original file stays in place; later segments never rename older data."""
21
+ parts = path.with_name(path.name + ".parts")
22
+ numbered = []
23
+ if parts.exists():
24
+ numbered.extend(
25
+ part
26
+ for part in parts.glob("*.jsonl")
27
+ if part.stem.isascii() and part.stem.isdecimal()
28
+ )
29
+ return [path, *sorted(numbered, key=lambda part: int(part.stem))]
@@ -38,6 +38,7 @@ from .naming import (
38
38
  validate_stream,
39
39
  )
40
40
  from .reader import JsonlReader, merge_steps, recent_steps
41
+ from .rotation import DEFAULT_MAX_FILE_BYTES, validate_file_limit
41
42
  from .series import DEFAULT_WINDOW, MetricSeries
42
43
  from .writer import (
43
44
  DEFAULT_BUFFER_INTERVAL,
@@ -115,6 +116,7 @@ class HistoryOptions:
115
116
  max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS
116
117
  max_pending_bytes: int | None = DEFAULT_MAX_PENDING_BYTES
117
118
  max_record_bytes: int | None = DEFAULT_MAX_RECORD_BYTES
119
+ max_file_bytes: int | None = DEFAULT_MAX_FILE_BYTES
118
120
  # in-memory cache
119
121
  cache_bytes: int = DEFAULT_CACHE_BYTES
120
122
  cache_rows: int = DEFAULT_CACHE_ROWS
@@ -138,6 +140,7 @@ class HistoryOptions:
138
140
 
139
141
  def __post_init__(self):
140
142
  validate_byte_limits(self.max_pending_bytes, self.max_record_bytes)
143
+ validate_file_limit(self.max_file_bytes)
141
144
  if self.step_policy not in STEP_POLICIES:
142
145
  raise ValueError(
143
146
  f"Unknown step_policy {self.step_policy!r}; "
@@ -304,6 +307,7 @@ class HistoryStore:
304
307
  max_pending_records=opts.max_pending_records,
305
308
  max_pending_bytes=opts.max_pending_bytes,
306
309
  max_record_bytes=opts.max_record_bytes,
310
+ max_file_bytes=opts.max_file_bytes,
307
311
  async_write=opts.span_async_write,
308
312
  )
309
313
  if self.spans_fp is not None
@@ -336,6 +340,7 @@ class HistoryStore:
336
340
  max_pending_records=options.max_pending_records,
337
341
  max_pending_bytes=options.max_pending_bytes,
338
342
  max_record_bytes=options.max_record_bytes,
343
+ max_file_bytes=options.max_file_bytes,
339
344
  meta_extra={
340
345
  "project": self.project,
341
346
  "name": self.name,
@@ -1095,9 +1100,12 @@ class HistoryStore:
1095
1100
  records = view.records()
1096
1101
  if view.complete:
1097
1102
  return records
1098
- return self._older(
1099
- lambda: self._older_tail(view, steps, records + opened, merge=merge)
1100
- ) + records
1103
+ return (
1104
+ self._older(
1105
+ lambda: self._older_tail(view, steps, records + opened, merge=merge)
1106
+ )
1107
+ + records
1108
+ )
1101
1109
 
1102
1110
  def _older(self, fetch: Callable[[], list[dict]]) -> list[dict]:
1103
1111
  """Rows from before the cache; an unreadable file degrades to cache-only.
@@ -18,6 +18,12 @@ from loguru import logger
18
18
 
19
19
  from .._process import check_process, process_id, register_fork_reset
20
20
  from .reader import JsonlReader
21
+ from .rotation import (
22
+ DEFAULT_MAX_FILE_BYTES,
23
+ segment_path,
24
+ segments,
25
+ validate_file_limit,
26
+ )
21
27
 
22
28
  DEFAULT_BUFFER_SIZE = 50
23
29
  DEFAULT_BUFFER_INTERVAL = 1.0
@@ -140,6 +146,9 @@ class JsonlWriter:
140
146
  ``max_pending_bytes``. Both byte limits must be positive integers or ``None``.
141
147
  - ``async_write``: enqueue without producer disk I/O; ``flush`` and ``close``
142
148
  still wait for accepted writes. The default preserves synchronous flushing.
149
+ - ``max_file_bytes``: rotate before a record would exceed this file size.
150
+ All segments are retained; a larger record occupies a segment by itself.
151
+ ``None`` disables rotation.
143
152
  """
144
153
 
145
154
  def __init__(
@@ -152,13 +161,19 @@ class JsonlWriter:
152
161
  max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS,
153
162
  max_pending_bytes: int | None = DEFAULT_MAX_PENDING_BYTES,
154
163
  max_record_bytes: int | None = DEFAULT_MAX_RECORD_BYTES,
164
+ max_file_bytes: int | None = DEFAULT_MAX_FILE_BYTES,
155
165
  index_every: int = DEFAULT_INDEX_EVERY,
156
166
  meta_extra: dict | None = None,
157
167
  async_write: bool = False,
158
168
  ):
159
169
  self._owner_pid = process_id()
160
170
  validate_byte_limits(max_pending_bytes, max_record_bytes)
171
+ validate_file_limit(max_file_bytes)
161
172
  self.path = Path(path)
173
+ self.max_file_bytes = max_file_bytes
174
+ active = segments(self.path)[-1]
175
+ self._active_number = 0 if active == self.path else int(active.stem)
176
+ self._active_size = 0
162
177
  self.meta_path = self.path.with_name(self.path.stem + ".meta.json")
163
178
  self.buffer_size = max(1, int(buffer_size))
164
179
  self.buffer_interval = (
@@ -213,12 +228,21 @@ class JsonlWriter:
213
228
 
214
229
  # ------------------------------------------------------------------ open/resume
215
230
 
231
+ @property
232
+ def _active_path(self) -> Path:
233
+ return (
234
+ self.path
235
+ if self._active_number == 0
236
+ else segment_path(self.path, self._active_number)
237
+ )
238
+
216
239
  def _load(self):
217
240
  """Restore state from an existing file, repairing a torn tail and stale meta."""
218
241
  if not self.path.exists():
219
242
  return
220
243
  self._repair_torn_tail()
221
- size = self.path.stat().st_size
244
+ self._active_size = self._active_path.stat().st_size
245
+ size = JsonlReader(self.path).size
222
246
  if not self._restore_from_meta(size):
223
247
  self._rebuild_from_disk(size)
224
248
  self.size = size
@@ -228,10 +252,10 @@ class JsonlWriter:
228
252
 
229
253
  def _repair_torn_tail(self):
230
254
  """Drop a partially written final line so appends cannot glue onto it."""
231
- size = self.path.stat().st_size
255
+ size = self._active_path.stat().st_size
232
256
  if size == 0:
233
257
  return
234
- with open(self.path, "rb") as f:
258
+ with open(self._active_path, "rb") as f:
235
259
  f.seek(size - 1)
236
260
  if f.read(1) == b"\n":
237
261
  return
@@ -245,7 +269,7 @@ class JsonlWriter:
245
269
  cut = pos + index + 1
246
270
  break
247
271
  try:
248
- os.truncate(self.path, cut)
272
+ os.truncate(self._active_path, cut)
249
273
  except Exception as e:
250
274
  self._diagnostics.add(
251
275
  "error", f"Could not truncate torn tail of {self.path}: {e}"
@@ -253,7 +277,7 @@ class JsonlWriter:
253
277
  return
254
278
  self._diagnostics.add(
255
279
  "warning",
256
- f"Dropped {size - cut} bytes of a torn final line in {self.path}."
280
+ f"Dropped {size - cut} bytes of a torn final line in {self.path}.",
257
281
  )
258
282
 
259
283
  def _restore_from_meta(self, size: int) -> bool:
@@ -293,12 +317,9 @@ class JsonlWriter:
293
317
  def _rescan(self, offset: int, line_no: int):
294
318
  """Fold every line from ``offset`` onwards back into the counters and index."""
295
319
  self.lines = self._next_index_line = line_no
296
- with open(self.path, "rb") as f:
297
- f.seek(offset)
298
- for raw in f:
299
- if raw.strip():
300
- self._track_line(_peek_step(raw), offset)
301
- offset += len(raw)
320
+ for position, raw in JsonlReader(self.path)._snapshot()._scan(offset):
321
+ if raw.strip():
322
+ self._track_line(_peek_step(raw), position)
302
323
 
303
324
  def _track_line(self, step: int | None, offset: int):
304
325
  """Account for one line that is already on disk."""
@@ -449,7 +470,8 @@ class JsonlWriter:
449
470
  if self._first_buffered_time is None:
450
471
  self._first_buffered_time = now
451
472
  interval = (
452
- None if self._last_append_time is None
473
+ None
474
+ if self._last_append_time is None
453
475
  else now - self._last_append_time
454
476
  )
455
477
  self._last_append_time = now
@@ -592,13 +614,42 @@ class JsonlWriter:
592
614
  self._save_meta()
593
615
 
594
616
  def _write(self, payload: bytes):
617
+ """Split a batch at record boundaries, without renaming pinned segments."""
618
+ if (
619
+ self.max_file_bytes is None
620
+ or self._active_size + len(payload) <= self.max_file_bytes
621
+ ):
622
+ self._append(payload)
623
+ self._active_size += len(payload)
624
+ return
625
+ start = 0
626
+ while start < len(payload):
627
+ end = start
628
+ while end < len(payload):
629
+ next_end = payload.find(b"\n", end) + 1
630
+ if next_end == 0:
631
+ next_end = len(payload)
632
+ if self._active_size + next_end - start > self.max_file_bytes and (
633
+ self._active_size or end > start
634
+ ):
635
+ break
636
+ end = next_end
637
+ if end == start:
638
+ self._active_number += 1
639
+ self._active_size = 0
640
+ continue
641
+ self._append(payload[start:end])
642
+ self._active_size += end - start
643
+ start = end
644
+
645
+ def _append(self, payload: bytes):
595
646
  """Append, recreating the parent directory once if it disappeared."""
596
647
  try:
597
- with open(self.path, "ab") as f:
648
+ with open(self._active_path, "ab") as f:
598
649
  f.write(payload)
599
650
  except FileNotFoundError:
600
- self.path.parent.mkdir(parents=True, exist_ok=True)
601
- with open(self.path, "ab") as f:
651
+ self._active_path.parent.mkdir(parents=True, exist_ok=True)
652
+ with open(self._active_path, "ab") as f:
602
653
  f.write(payload)
603
654
 
604
655
  def _track_flushed(self, records: deque[tuple[int, int, bytes]], offset: int):
@@ -625,9 +676,23 @@ class JsonlWriter:
625
676
  def _truncate_partial_write(self, size_before: int) -> bool:
626
677
  """Roll back a partial write so retries cannot duplicate lines."""
627
678
  try:
628
- if not self.path.is_file() or self.path.stat().st_size <= size_before:
629
- return True # nothing reached the file
630
- os.truncate(self.path, size_before)
679
+ remaining = size_before
680
+ for path in segments(self.path):
681
+ if not path.exists():
682
+ continue
683
+ length = path.stat().st_size
684
+ if remaining >= length:
685
+ remaining -= length
686
+ elif remaining or path == self.path:
687
+ os.truncate(path, remaining)
688
+ remaining = 0
689
+ else:
690
+ path.unlink()
691
+ active = segments(self.path)[-1]
692
+ self._active_number = 0 if active == self.path else int(active.stem)
693
+ self._active_size = (
694
+ self._active_path.stat().st_size if self._active_path.exists() else 0
695
+ )
631
696
  return True
632
697
  except Exception as e:
633
698
  self._diagnostics.add(
@@ -645,13 +710,12 @@ class JsonlWriter:
645
710
  self._first_buffered_time = time.monotonic()
646
711
 
647
712
  def _has_capacity(self, incoming_bytes: int) -> bool:
648
- return (
649
- len(self._buffer) + self._inflight_records < self.max_pending_records
650
- and (
651
- self.max_pending_bytes is None
652
- or self._queued_bytes + self._inflight_bytes + incoming_bytes
653
- <= self.max_pending_bytes
654
- )
713
+ return len(
714
+ self._buffer
715
+ ) + self._inflight_records < self.max_pending_records and (
716
+ self.max_pending_bytes is None
717
+ or self._queued_bytes + self._inflight_bytes + incoming_bytes
718
+ <= self.max_pending_bytes
655
719
  )
656
720
 
657
721
  def _cap_buffer(self, incoming_bytes: int) -> tuple[bool, str]:
@@ -751,7 +815,7 @@ class JsonlWriter:
751
815
  "error",
752
816
  f"Closing {self.path} with {len(self._buffer)} "
753
817
  "unwritten records after a failed final flush "
754
- f"({self._queued_bytes} bytes)."
818
+ f"({self._queued_bytes} bytes).",
755
819
  )
756
820
  finally:
757
821
  with self._condition:
@@ -18,6 +18,7 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory
18
18
  from typing import Any
19
19
 
20
20
  from .history.naming import parse_stream, spans_filename
21
+ from .history.reader import JsonlReader
21
22
 
22
23
  MICROSECONDS = 1_000_000.0
23
24
 
@@ -90,7 +91,7 @@ def _iter_spans(
90
91
  if not path.is_file():
91
92
  return
92
93
  # Binary lines isolate invalid UTF-8 to one record, like malformed JSON.
93
- with path.open("rb") as source:
94
+ with closing(JsonlReader(path).iter_raw()) as source:
94
95
  for line in source:
95
96
  try:
96
97
  record = json.loads(line)
@@ -0,0 +1,217 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ import expr_tracker as et
6
+ from expr_tracker.history import HistoryStore, JsonlReader, JsonlWriter, read_history
7
+ from expr_tracker.history.rotation import DEFAULT_MAX_FILE_BYTES, segments
8
+ from expr_tracker.trace import build_trace, read_spans
9
+
10
+
11
+ def line(_step, **fields):
12
+ return json.dumps({"_step": _step, **fields}).encode() + b"\n"
13
+
14
+
15
+ def write(writer, records):
16
+ start = writer.lines
17
+ for row, record in enumerate(records, start):
18
+ writer.enqueue(record["_step"], row, line(**record))
19
+ writer.flush()
20
+
21
+
22
+ def test_default_and_validation(tmp_path):
23
+ writer = JsonlWriter(tmp_path / "metrics.jsonl")
24
+ assert writer.max_file_bytes == DEFAULT_MAX_FILE_BYTES == 100 << 20
25
+ writer.close()
26
+ for invalid in (0, -1, True, 1.5, "100"):
27
+ with pytest.raises(ValueError, match="max_file_bytes"):
28
+ JsonlWriter(tmp_path / "metrics.jsonl", max_file_bytes=invalid)
29
+ with pytest.raises(ValueError, match="max_file_bytes"):
30
+ HistoryStore().init("p", "n", dir=str(tmp_path), max_file_bytes=invalid)
31
+
32
+
33
+ @pytest.mark.parametrize("async_write", [False, True])
34
+ def test_batch_rotation_and_resume(tmp_path, async_write):
35
+ path = tmp_path / "metrics.jsonl"
36
+ records = [{"_step": step, "v": step} for step in range(20)]
37
+ writer = JsonlWriter(
38
+ path, max_file_bytes=80, max_buffer_seconds=None, async_write=async_write
39
+ )
40
+ write(writer, records)
41
+ writer.close()
42
+ assert len(segments(path)) > 2
43
+ assert all(part.stat().st_size <= 80 for part in segments(path))
44
+ reader = JsonlReader(path)
45
+ assert reader.read_all() == records
46
+ assert reader.tail(3) == records[-3:]
47
+ assert reader.read_steps(3, 8) == records[3:8]
48
+ assert reader.count_lines() == 20
49
+ for row in range(21):
50
+ boundary = reader.offset_of_line(row)
51
+ assert reader.tail_rows(3, end=boundary) == records[max(0, row - 3) : row]
52
+ assert list(reader.iter_raw(end=boundary)) == [
53
+ line(**record) for record in records[:row]
54
+ ]
55
+ if boundary:
56
+ assert list(reader.iter_raw(end=boundary - 1)) == [
57
+ line(**record) for record in records[: row - 1]
58
+ ]
59
+ assert list(reader.iter_raw(reader.offset_of_line(7))) == [
60
+ line(**record) for record in records[7:]
61
+ ]
62
+ resumed = JsonlWriter(path, max_file_bytes=None)
63
+ assert resumed.lines == 20
64
+ assert resumed.max_step == 19
65
+ write(resumed, [{"_step": 20, "v": 20}])
66
+ resumed.close()
67
+ assert JsonlReader(path).read_all() == [*records, {"_step": 20, "v": 20}]
68
+
69
+
70
+ def test_boundary_oversize_and_disabled(tmp_path):
71
+ raw = line(0, v="x")
72
+ path = tmp_path / "metrics.jsonl"
73
+ writer = JsonlWriter(path, max_file_bytes=len(raw))
74
+ write(
75
+ writer,
76
+ [
77
+ {"_step": 0, "v": "x"},
78
+ {"_step": 1, "v": "x" * 100},
79
+ {"_step": 2, "v": "x"},
80
+ ],
81
+ )
82
+ writer.close()
83
+ assert [part.stat().st_size for part in segments(path)] == [
84
+ len(raw),
85
+ len(line(1, v="x" * 100)),
86
+ len(raw),
87
+ ]
88
+ disabled = JsonlWriter(tmp_path / "disabled.jsonl", max_file_bytes=None)
89
+ write(disabled, [{"_step": i} for i in range(10)])
90
+ disabled.close()
91
+ assert len(segments(disabled.path)) == 1
92
+
93
+
94
+ def test_snapshot_and_patches_across_segments(tmp_path):
95
+ path = tmp_path / "metrics.jsonl"
96
+ writer = JsonlWriter(path, max_file_bytes=30)
97
+ write(writer, [{"_step": 0, "a": 1}, {"_step": 1, "a": 2}])
98
+ snapshot = writer.reader()._snapshot()
99
+ size = snapshot.size
100
+ write(writer, [{"_step": 0, "b": 3}, {"_step": 2, "a": 4}])
101
+ assert snapshot.size == size
102
+ assert snapshot.read_all() == [{"_step": 0, "a": 1}, {"_step": 1, "a": 2}]
103
+ assert writer.reader().tail(2) == [
104
+ {"_step": 0, "a": 1, "b": 3},
105
+ {"_step": 2, "a": 4},
106
+ ]
107
+ writer.close()
108
+ path.with_name("metrics.meta.json").unlink()
109
+ resumed = JsonlWriter(path, max_file_bytes=30)
110
+ assert resumed.lines == 4
111
+ assert resumed.max_step == 2
112
+ assert not resumed.sorted
113
+ resumed.close()
114
+
115
+
116
+ def test_partial_write_rollback_across_segments(tmp_path, monkeypatch):
117
+ writer = JsonlWriter(
118
+ tmp_path / "metrics.jsonl", max_file_bytes=30, max_buffer_seconds=None
119
+ )
120
+ write(writer, [{"_step": 0}])
121
+ original = writer._append
122
+ calls = 0
123
+
124
+ def fail(payload):
125
+ nonlocal calls
126
+ calls += 1
127
+ if calls == 2:
128
+ original(payload[:5])
129
+ raise OSError("disk unavailable")
130
+ original(payload)
131
+
132
+ monkeypatch.setattr(writer, "_append", fail)
133
+ write(writer, [{"_step": i} for i in range(1, 5)])
134
+ assert writer.reader().read_all() == [{"_step": 0}]
135
+ monkeypatch.setattr(writer, "_append", original)
136
+ writer.flush()
137
+ writer.close()
138
+ assert JsonlReader(writer.path).read_all() == [{"_step": i} for i in range(5)]
139
+
140
+
141
+ def test_live_offline_and_torn_tail(tmp_path):
142
+ store = HistoryStore().init(
143
+ "p",
144
+ "n",
145
+ dir=str(tmp_path),
146
+ max_file_bytes=100,
147
+ cache_rows=1,
148
+ cache_bytes=100,
149
+ max_buffer_seconds=None,
150
+ buffer_size=1,
151
+ )
152
+ for step in range(12):
153
+ store.log({"v": step}, step=step, commit=True)
154
+ store.flush()
155
+ assert [row["v"] for row in store.get(-1, output_type="records")] == list(range(12))
156
+ path = store.log_fp
157
+ store.finish()
158
+ assert [row["v"] for row in read_history(path, -1, output_type="records")] == list(
159
+ range(12)
160
+ )
161
+ with segments(path)[-1].open("ab") as source:
162
+ source.write(b'{"_step":')
163
+ resumed = JsonlWriter(path, max_file_bytes=100)
164
+ assert resumed.lines == 12
165
+ assert resumed.max_step == 11
166
+ resumed.close()
167
+
168
+
169
+ def test_spans_include_rotated_records(tmp_path):
170
+ path = tmp_path / "spans.jsonl"
171
+ writer = JsonlWriter(path, max_file_bytes=100)
172
+ records = [
173
+ {"_step": i, "name": f"span{i}", "start": i, "dur_ms": 1} for i in range(5)
174
+ ]
175
+ write(writer, records)
176
+ writer.close()
177
+ assert [span["name"] for span in read_spans(path)] == [
178
+ record["name"] for record in records
179
+ ]
180
+ assert [
181
+ event["name"]
182
+ for event in build_trace(tmp_path)["traceEvents"]
183
+ if event["ph"] == "X"
184
+ ] == [record["name"] for record in records]
185
+
186
+
187
+ @pytest.mark.parametrize("rank", [0, 2])
188
+ def test_public_history_stream_rank_spans_and_resume(run, monkeypatch, rank):
189
+ monkeypatch.setenv("RANK", str(rank))
190
+ instance = run(
191
+ stream="data",
192
+ max_file_bytes=100,
193
+ cache_rows=1,
194
+ buffer_size=1,
195
+ max_buffer_seconds=None,
196
+ )
197
+ for step in range(8):
198
+ with et.span("work"):
199
+ pass
200
+ et.log({"v": step})
201
+ instance.history.flush()
202
+ assert instance.history.writer.max_file_bytes == 100
203
+ assert instance.history.span_writer.max_file_bytes == 100
204
+ path = instance.history.log_fp
205
+ span_path = instance.history.spans_fp
206
+ assert len(segments(path)) > 1
207
+ assert [row["v"] for row in et.history(-1, output_type="records")] == list(range(8))
208
+ et.finish()
209
+ assert len(read_spans(span_path)) == 8
210
+ assert [
211
+ row["v"]
212
+ for row in et.history(-1, run=path.parent, stream="data", output_type="records")
213
+ ] == list(range(8))
214
+ resumed = run(stream="data", max_file_bytes=100, cache_rows=1, buffer_size=1)
215
+ assert resumed.history.current_step == 8
216
+ et.log({"v": 8})
217
+ assert [row["v"] for row in et.history(-1, output_type="records")] == list(range(9))
File without changes
File without changes
File without changes
File without changes
File without changes