expr_tracker 0.2.2__py3-none-any.whl → 0.2.3__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.
expr_tracker/__init__.py CHANGED
@@ -19,6 +19,7 @@ from .alerts import (
19
19
  )
20
20
  from .artifacts import Artifact
21
21
  from .run import Run
22
+ from .spans import Span, span, start_span
22
23
 
23
24
  try:
24
25
  __version__ = _version("expr_tracker")
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "ChannelConfig",
46
47
  "M",
47
48
  "Run",
49
+ "Span",
48
50
  "WebhookPolicy",
49
51
  "__version__",
50
52
  "add_alert_rule",
@@ -61,6 +63,8 @@ __all__ = [
61
63
  "log_artifact",
62
64
  "register_backend",
63
65
  "remove_alert_rule",
66
+ "span",
67
+ "start_span",
64
68
  "summary",
65
69
  "use_artifact",
66
70
  ]
expr_tracker/cli.py CHANGED
@@ -13,6 +13,7 @@ from .alerts.engine import AlertEngine
13
13
  from .alerts.expr import EvalContext, compile_condition, parse_rule, validate
14
14
  from .alerts.models import AlertConfig, ChannelConfig, WebhookPolicy
15
15
  from .history import MetricSeries, read_history
16
+ from .trace import write_trace
16
17
 
17
18
 
18
19
  @click.group()
@@ -54,6 +55,29 @@ def history(run: str, n: int, metrics: str | None, step_range: str | None, fmt:
54
55
  click.echo(_to_table(rows))
55
56
 
56
57
 
58
+ @main.command()
59
+ @click.argument("run", type=click.Path(exists=True))
60
+ @click.option("-o", "--output", default="trace.json", help="Where to write the trace")
61
+ @click.option(
62
+ "--stream",
63
+ default=None,
64
+ help="Comma separated streams; every stream by default. Use 'default' for the "
65
+ "unnamed one.",
66
+ )
67
+ @click.option("--step-range", default=None, help="start:end (end exclusive)")
68
+ def trace(run: str, output: str, stream: str | None, step_range: str | None):
69
+ """Export recorded spans as a Chrome Trace.
70
+
71
+ Open the result at https://ui.perfetto.dev or chrome://tracing. It can be
72
+ loaded beside a torch.profiler trace.
73
+ """
74
+ wanted: str | list[str] | None = "*"
75
+ if stream:
76
+ wanted = [None if s in ("default", "") else s for s in stream.split(",")]
77
+ count = write_trace(run, output, wanted, step_range=_parse_range(step_range))
78
+ click.echo(f"wrote {count} span(s) to {output}")
79
+
80
+
57
81
  @main.group()
58
82
  def rules():
59
83
  """Alert rule tooling."""
@@ -1,8 +1,15 @@
1
1
  from .codec import RecordCodec
2
2
  from .frame import project, to_output
3
- from .reader import JsonlReader, merge_steps, read_history, resolve_run_path
3
+ from .naming import current_rank, metrics_filename, parse_stream, validate_stream
4
+ from .reader import (
5
+ JsonlReader,
6
+ list_streams,
7
+ merge_steps,
8
+ read_history,
9
+ resolve_run_path,
10
+ )
4
11
  from .series import MetricSeries
5
- from .store import HistoryStore, current_rank, resolve_commit
12
+ from .store import HistoryStore, resolve_commit
6
13
  from .writer import JsonlWriter
7
14
 
8
15
  __all__ = [
@@ -12,10 +19,14 @@ __all__ = [
12
19
  "MetricSeries",
13
20
  "RecordCodec",
14
21
  "current_rank",
22
+ "list_streams",
15
23
  "merge_steps",
24
+ "metrics_filename",
25
+ "parse_stream",
16
26
  "project",
17
27
  "read_history",
18
28
  "resolve_commit",
19
29
  "resolve_run_path",
20
30
  "to_output",
31
+ "validate_stream",
21
32
  ]
@@ -0,0 +1,74 @@
1
+ """Run file naming: distributed rank shards and independent producer streams.
2
+
3
+ Kept separate from the store and the reader because both need it, and the store
4
+ already depends on the reader.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+
12
+ STREAM_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
13
+ RANK_PATTERN = re.compile(r"rank\d+")
14
+
15
+
16
+ def current_rank() -> int:
17
+ """Distributed rank from the usual environment variables, 0 when unset."""
18
+ rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
19
+ try:
20
+ return int(rank)
21
+ except ValueError:
22
+ return 0
23
+
24
+
25
+ def validate_stream(stream: str) -> str:
26
+ """Check a stream name is safe as a filename component.
27
+
28
+ The name becomes part of ``metrics.<stream>[.rankN].jsonl``, so it must not
29
+ contain a separator, and must not look like a rank shard.
30
+ """
31
+ if not STREAM_PATTERN.match(stream):
32
+ raise ValueError(
33
+ f"Invalid stream name {stream!r}; use letters, digits, '_' or '-', "
34
+ "starting with a letter or digit."
35
+ )
36
+ if RANK_PATTERN.fullmatch(stream):
37
+ raise ValueError(
38
+ f"Stream name {stream!r} collides with the rank shard naming; "
39
+ "pick another name."
40
+ )
41
+ return stream
42
+
43
+
44
+ def metrics_filename(stream: str | None, rank_aware: bool = True) -> str:
45
+ """``metrics[.stream][.rankN].jsonl``.
46
+
47
+ Streams separate independent producers; non-zero ranks get their own shard so
48
+ concurrent appends cannot interleave. The two compose.
49
+ """
50
+ rank = current_rank() if rank_aware else 0
51
+ parts = ["metrics"]
52
+ if stream:
53
+ parts.append(stream)
54
+ if rank > 0:
55
+ parts.append(f"rank{rank}")
56
+ return ".".join(parts) + ".jsonl"
57
+
58
+
59
+ def spans_filename(stream: str | None, rank_aware: bool = True) -> str:
60
+ """``spans[.stream][.rankN].jsonl``, alongside the metrics file."""
61
+ return metrics_filename(stream, rank_aware).replace("metrics", "spans", 1)
62
+
63
+
64
+ def sidecar_filename(base: str, stream: str | None, suffix: str) -> str:
65
+ """``<base>[.stream].<suffix>``, so producers do not overwrite each other."""
66
+ return f"{base}.{stream}.{suffix}" if stream else f"{base}.{suffix}"
67
+
68
+
69
+ def parse_stream(filename: str) -> str | None:
70
+ """The stream a metrics filename belongs to; ``None`` is the default producer."""
71
+ parts = filename.split(".")[1:-1] # drop "metrics" and "jsonl"
72
+ if parts and RANK_PATTERN.fullmatch(parts[-1]):
73
+ parts = parts[:-1]
74
+ return parts[0] if parts else None
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from loguru import logger
15
15
 
16
16
  from .frame import project, to_output
17
+ from .naming import RANK_PATTERN, metrics_filename
17
18
 
18
19
  CHUNK_SIZE = 256 * 1024
19
20
 
@@ -317,6 +318,7 @@ def read_history(
317
318
  run: str | Path,
318
319
  n: int | None = 50,
319
320
  *,
321
+ stream: str | None = None,
320
322
  output_type: str = "dict",
321
323
  metrics: Sequence[str] | None = None,
322
324
  step_range: tuple[int | None, int | None] | None = None,
@@ -325,7 +327,7 @@ def read_history(
325
327
  dropna: bool = False,
326
328
  ):
327
329
  """Read any run's history without ``init()``; ``run`` is a directory or file."""
328
- reader = JsonlReader(resolve_run_path(run))
330
+ reader = JsonlReader(resolve_run_path(run, stream))
329
331
  limit = None if n is None or n < 0 else max(0, n)
330
332
  if limit == 0:
331
333
  records: list[dict] = []
@@ -347,13 +349,41 @@ def read_history(
347
349
  return to_output(rows, output_type)
348
350
 
349
351
 
350
- def resolve_run_path(run: str | Path) -> Path:
352
+ def resolve_run_path(run: str | Path, stream: str | None = None) -> Path:
353
+ """The metrics file of a run directory, or the file itself.
354
+
355
+ Names are matched exactly rather than by sort order: ``metrics.data.jsonl``
356
+ sorts before ``metrics.jsonl``, so picking the first match would silently
357
+ return a stream instead of the default producer.
358
+ """
351
359
  path = Path(run)
352
- if path.is_dir():
353
- candidates = sorted(path.glob("metrics*.jsonl"))
354
- if not candidates:
355
- raise FileNotFoundError(f"No metrics*.jsonl found under {path}")
356
- return candidates[0]
357
- if not path.exists():
358
- raise FileNotFoundError(f"Run path does not exist: {path}")
359
- return path
360
+ if not path.is_dir():
361
+ if not path.exists():
362
+ raise FileNotFoundError(f"Run path does not exist: {path}")
363
+ return path
364
+ wanted = metrics_filename(stream, rank_aware=False)
365
+ exact = path / wanted
366
+ if exact.is_file():
367
+ return exact
368
+ # Fall back to this stream's rank shards, which a worker rank writes instead
369
+ prefix = wanted[: -len(".jsonl")]
370
+ shards = sorted(path.glob(f"{prefix}.rank*.jsonl"))
371
+ if shards:
372
+ return shards[0]
373
+ raise FileNotFoundError(f"No {wanted} found under {path}; {_available(path)}")
374
+
375
+
376
+ def _available(path: Path) -> str:
377
+ found = sorted(p.name for p in path.glob("metrics*.jsonl"))
378
+ return f"available: {', '.join(found)}" if found else "the directory has none"
379
+
380
+
381
+ def list_streams(run: str | Path) -> list[str | None]:
382
+ """Every stream in a run directory; ``None`` is the default producer."""
383
+ streams: set[str | None] = set()
384
+ for path in Path(run).glob("metrics*.jsonl"):
385
+ parts = path.name.split(".")[1:-1] # drop "metrics" and "jsonl"
386
+ if parts and RANK_PATTERN.fullmatch(parts[-1]):
387
+ parts = parts[:-1]
388
+ streams.add(parts[0] if parts else None)
389
+ return sorted(streams, key=lambda s: (s is not None, s or ""))
@@ -13,7 +13,6 @@ from __future__ import annotations
13
13
  import atexit
14
14
  import json
15
15
  import operator
16
- import os
17
16
  import threading
18
17
  import time
19
18
  from collections import deque
@@ -25,6 +24,12 @@ from loguru import logger
25
24
 
26
25
  from .codec import RESERVED_KEYS, RecordCodec, encode_line
27
26
  from .frame import project, to_output
27
+ from .naming import (
28
+ metrics_filename,
29
+ sidecar_filename,
30
+ spans_filename,
31
+ validate_stream,
32
+ )
28
33
  from .reader import JsonlReader, merge_steps
29
34
  from .series import DEFAULT_WINDOW, MetricSeries
30
35
  from .writer import (
@@ -65,6 +70,10 @@ class HistoryOptions:
65
70
  max_open_seconds: float | None = DEFAULT_MAX_OPEN_SECONDS
66
71
  step_policy: str = "monotonic"
67
72
  rank_aware: bool = True
73
+ # an independent producer writing its own file with its own step cursor
74
+ stream: str | None = None
75
+ # write the full span tree to spans.jsonl beside the metrics
76
+ spans: bool = True
68
77
  # alerts and output
69
78
  alert_window: int = DEFAULT_WINDOW
70
79
  print_to_screen: bool = False
@@ -76,6 +85,8 @@ class HistoryOptions:
76
85
  f"Unknown step_policy {self.step_policy!r}; "
77
86
  f"expected one of {', '.join(STEP_POLICIES)}."
78
87
  )
88
+ if self.stream is not None:
89
+ object.__setattr__(self, "stream", validate_stream(self.stream))
79
90
  clamped = {
80
91
  "cache_bytes": max(0, int(self.cache_bytes)),
81
92
  "cache_rows": max(1, int(self.cache_rows)),
@@ -128,6 +139,9 @@ class HistoryStore:
128
139
  self.log_dir: Path | None = None
129
140
  self.log_fp: Path | None = None
130
141
  self.config_fp: Path | None = None
142
+ self.spans_fp: Path | None = None
143
+ self.span_writer: JsonlWriter | None = None
144
+ self.stream: str | None = None
131
145
  self.project: str = ""
132
146
  self.name: str = ""
133
147
  self._reset(HistoryOptions(), on_commit=None)
@@ -177,12 +191,29 @@ class HistoryStore:
177
191
  self.project, self.name = project, name
178
192
  self.log_dir = Path(dir or "./tracker/jsonl") / project / name
179
193
  self.log_dir.mkdir(parents=True, exist_ok=True)
180
- self.config_fp = self.log_dir / "config.json"
181
- self.log_fp = self.log_dir / _metrics_filename(opts.rank_aware)
194
+ self.stream = opts.stream
195
+ self.config_fp = self.log_dir / sidecar_filename("config", opts.stream, "json")
196
+ self.spans_fp = (
197
+ self.log_dir / spans_filename(opts.stream, opts.rank_aware)
198
+ if opts.spans
199
+ else None
200
+ )
201
+ self.log_fp = self.log_dir / metrics_filename(opts.stream, opts.rank_aware)
182
202
 
183
203
  self._close_previous()
184
204
  self._reset(opts, on_commit)
185
205
  self.writer = self._open_writer(opts)
206
+ self.span_writer = (
207
+ JsonlWriter(
208
+ self.spans_fp,
209
+ buffer_size=opts.buffer_size,
210
+ buffer_interval=opts.buffer_interval,
211
+ max_buffer_seconds=opts.max_buffer_seconds,
212
+ max_pending_records=opts.max_pending_records,
213
+ )
214
+ if self.spans_fp is not None
215
+ else None
216
+ )
186
217
  self._resume(opts.alert_window)
187
218
  self._write_config(config)
188
219
  self._register_atexit()
@@ -191,8 +222,9 @@ class HistoryStore:
191
222
  def _close_previous(self):
192
223
  """Re-initialising must not strand the previous writer's buffer or timer."""
193
224
  self._cancel_open_timer()
194
- if self.writer is not None:
195
- self.writer.close()
225
+ for writer in (self.writer, self.span_writer):
226
+ if writer is not None:
227
+ writer.close()
196
228
 
197
229
  def _open_writer(self, options: HistoryOptions) -> JsonlWriter:
198
230
  assert self.log_fp is not None
@@ -276,7 +308,24 @@ class HistoryStore:
276
308
  ``is None``, since step 0 is falsy.
277
309
  """
278
310
  self._require_init()
279
- encoded = self._codec.encode(metrics)
311
+ return self.ingest(self._codec.encode(metrics), step=step, commit=commit)
312
+
313
+ def ingest(
314
+ self,
315
+ encoded: dict,
316
+ *,
317
+ step: int | None = None,
318
+ commit: bool | None = None,
319
+ accumulate: bool = False,
320
+ ) -> int | None:
321
+ """The single path metrics take into the open row.
322
+
323
+ ``log()`` and span durations both come through here, so the step policy,
324
+ the merge and the commit rules only exist once. ``accumulate`` adds to any
325
+ existing value instead of replacing it, which is what repeated spans in
326
+ one step need.
327
+ """
328
+ self._require_init()
280
329
  now = time.time()
281
330
  pending: list[dict] = []
282
331
  with self._lock:
@@ -287,7 +336,7 @@ class HistoryStore:
287
336
  return None
288
337
  pending.append(self._switch_open_row(step, now))
289
338
  resolved = self._open_step
290
- self._update_open_row(encoded)
339
+ self._update_open_row(encoded, accumulate)
291
340
  if resolve_commit(step, commit):
292
341
  pending.append(self._close_open_row())
293
342
  for record in filter(None, pending):
@@ -295,6 +344,28 @@ class HistoryStore:
295
344
  self._schedule_open_timer()
296
345
  return resolved
297
346
 
347
+ def record_span(self, metrics: dict, record: dict | None = None) -> int | None:
348
+ """A finished span: durations join the open row, the tree goes to disk.
349
+
350
+ Durations never commit a step of their own -- they ride along with
351
+ whatever ``log()`` commits, so a span costs no extra row. The metrics are
352
+ built from floats and ints here, so they skip the encoder: spans are
353
+ recorded per sub-step and cannot afford it.
354
+ """
355
+ step = self.ingest(metrics, commit=False, accumulate=True)
356
+ if record is None or self.span_writer is None or step is None:
357
+ return step
358
+ try:
359
+ line = encode_line({"_step": step, **record})
360
+ except Exception as e:
361
+ logger.warning(f"Dropping span that cannot be serialized: {e}")
362
+ return step
363
+ # enqueue, not append: the batch goes out when the step commits, so a
364
+ # span does not pay for a flush decision of its own
365
+ if self.span_writer.enqueue(step, self.span_writer.lines, line):
366
+ self.span_writer.flush()
367
+ return step
368
+
298
369
  def _switch_open_row(self, step: int | None, now: float) -> dict | None:
299
370
  """Point the open row at ``step``, returning the row it displaced, if any.
300
371
 
@@ -331,12 +402,18 @@ class HistoryStore:
331
402
  if timer is not None:
332
403
  timer.cancel()
333
404
 
334
- def _update_open_row(self, encoded: dict):
405
+ def _update_open_row(self, encoded: dict, accumulate: bool = False):
335
406
  for key in RESERVED_KEYS & set(encoded):
336
407
  self._codec.warn_once(
337
408
  key, f"Metric name {key!r} is reserved and will be ignored."
338
409
  )
339
410
  encoded.pop(key)
411
+ if accumulate:
412
+ # Repeated spans in one step sum rather than overwrite
413
+ for key, value in encoded.items():
414
+ current = self._open_row.get(key)
415
+ self._open_row[key] = value + current if _addable(current) else value
416
+ return
340
417
  for key in encoded:
341
418
  if key in self._open_row:
342
419
  self._codec.warn_once(
@@ -480,8 +557,9 @@ class HistoryStore:
480
557
  record = self._close_open_row()
481
558
  if record is not None:
482
559
  self._emit(record)
483
- if self.writer is not None:
484
- self.writer.flush()
560
+ for writer in (self.writer, self.span_writer):
561
+ if writer is not None:
562
+ writer.flush()
485
563
 
486
564
  def finish(self):
487
565
  """Close the store. Never raises: a bad disk must not mask the real error."""
@@ -492,11 +570,12 @@ class HistoryStore:
492
570
  self.flush(commit_open=True)
493
571
  except Exception as e:
494
572
  logger.warning(f"Failed to flush history on finish: {e}")
495
- if self.writer is not None:
496
- try:
497
- self.writer.close()
498
- except Exception as e:
499
- logger.warning(f"Failed to close {self.log_fp}: {e}")
573
+ for writer in (self.writer, self.span_writer):
574
+ if writer is not None:
575
+ try:
576
+ writer.close()
577
+ except Exception as e:
578
+ logger.warning(f"Failed to close {writer.path}: {e}")
500
579
  if self._atexit_registered:
501
580
  atexit.unregister(self._atexit)
502
581
  self._atexit_registered = False
@@ -758,19 +837,8 @@ class HistoryStore:
758
837
  # ---------------------------------------------------------------------- helpers
759
838
 
760
839
 
761
- def current_rank() -> int:
762
- """Distributed rank from the usual environment variables, 0 when unset."""
763
- rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
764
- try:
765
- return int(rank)
766
- except ValueError:
767
- return 0
768
-
769
-
770
- def _metrics_filename(rank_aware: bool) -> str:
771
- """Non-zero ranks get their own shard so appends cannot interleave."""
772
- rank = current_rank() if rank_aware else 0
773
- return "metrics.jsonl" if rank <= 0 else f"metrics.rank{rank}.jsonl"
840
+ def _addable(value) -> bool:
841
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
774
842
 
775
843
 
776
844
  def _parse_cached(entries: Sequence[tuple[int, int, bytes]]) -> list[dict]:
expr_tracker/run.py CHANGED
@@ -17,6 +17,7 @@ from loguru import logger
17
17
 
18
18
  from .artifacts import Artifact, ArtifactStore, coerce_artifact
19
19
  from .history import HistoryStore, current_rank, resolve_commit
20
+ from .history.naming import sidecar_filename
20
21
  from .summary import Summary
21
22
 
22
23
  _lock = threading.RLock()
@@ -72,6 +73,7 @@ class Run:
72
73
  alert: Any = None,
73
74
  alert_rules: Sequence[Any] = (),
74
75
  alert_on_rank: int | None = 0,
76
+ stream: str | None = None,
75
77
  **history_kwargs,
76
78
  ):
77
79
  self.project = project
@@ -96,11 +98,15 @@ class Run:
96
98
  dir=dir,
97
99
  print_to_screen=print_to_screen,
98
100
  on_commit=self._on_commit,
101
+ stream=stream,
99
102
  **{**backend_kwargs.get("jsonl", {}), **history_kwargs},
100
103
  )
101
104
  self._closers.append(self.history.finish)
102
105
  self.name = self.history.name
103
- self.summary = Summary(self.history.log_dir / "summary.json")
106
+ self.stream = self.history.stream
107
+ self.summary = Summary(
108
+ self.history.log_dir / sidecar_filename("summary", self.stream, "json")
109
+ )
104
110
  # History survives a crash through its own atexit hook; the summary is
105
111
  # just as much a record of the run, so persist it the same way
106
112
  atexit.register(self._save_summary_at_exit)
@@ -150,9 +156,20 @@ class Run:
150
156
  self._rollback()
151
157
  raise
152
158
 
159
+ @property
160
+ def backend_run_name(self) -> str:
161
+ """A stream is an independent producer, so it gets its own backend run.
162
+
163
+ Grouping ties them back together: both wandb and trackio understand
164
+ ``group``, and neither can merge two step axes into one run.
165
+ """
166
+ return f"{self.name}-{self.stream}" if self.stream else self.name
167
+
153
168
  def _init_backend(
154
169
  self, name, instance, *, entity, dir, notes, tags, resume, kwargs
155
170
  ):
171
+ # A grouped run keeps the streams together in the backend UI
172
+ grouping = {"group": self.name} if self.stream else {}
156
173
  try:
157
174
  if name == "trackio":
158
175
  # trackio.init has no entity/notes/tags/dir/id, so those ride along
@@ -165,23 +182,27 @@ class Run:
165
182
  }
166
183
  instance.init(
167
184
  project=self.project,
168
- name=self.name,
185
+ name=self.backend_run_name,
169
186
  config=config,
170
187
  resume=_trackio_resume(resume),
171
- **kwargs,
188
+ **{**grouping, **kwargs},
172
189
  )
173
190
  else:
174
191
  instance.init(
175
192
  project=self.project,
176
- name=self.name,
193
+ name=self.backend_run_name,
177
194
  entity=entity,
178
195
  dir=dir,
179
196
  notes=notes,
180
197
  tags=tags,
181
198
  resume=resume,
182
- id=self.name,
199
+ id=self.backend_run_name,
183
200
  config=self.config,
184
- **kwargs,
201
+ **{
202
+ **grouping,
203
+ **({"job_type": self.stream} if self.stream else {}),
204
+ **kwargs,
205
+ },
185
206
  )
186
207
  except Exception as e:
187
208
  logger.warning(f"Failed to initialize backend {name}: {e}")
expr_tracker/spans.py ADDED
@@ -0,0 +1,236 @@
1
+ """Spans: how long each part of a step took, and how the parts nest.
2
+
3
+ A closed span adds its duration to the open row, so it becomes an ordinary metric
4
+ that ``history()``, alert rules and plots already understand. The full tree, with
5
+ timestamps and attributes, goes to ``spans.jsonl`` for trace viewers.
6
+
7
+ Durations never commit a step of their own; they ride along with whatever
8
+ ``log()`` commits.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import threading
15
+ import time
16
+ from contextvars import ContextVar
17
+ from typing import Any
18
+
19
+ from loguru import logger
20
+
21
+ # Per-thread and per-task by construction: a new thread starts from the default,
22
+ # and each asyncio task gets its own copy, which is exactly span nesting.
23
+ _STACK: ContextVar[tuple[Span, ...]] = ContextVar("et_span_stack", default=())
24
+
25
+ DURATION_SUFFIX = "duration_ms"
26
+ COUNT_SUFFIX = "count"
27
+
28
+
29
+ def current_span() -> Span | None:
30
+ stack = _STACK.get()
31
+ return stack[-1] if stack else None
32
+
33
+
34
+ def active_path() -> str:
35
+ """The ``/`` joined names of the spans currently open on this thread."""
36
+ return "/".join(span.name for span in _STACK.get())
37
+
38
+
39
+ class Span:
40
+ """One timed region. Prefer :func:`span`; construct it directly only when
41
+ you need to end it somewhere else."""
42
+
43
+ __slots__ = (
44
+ "_ended",
45
+ "_store",
46
+ "_token",
47
+ "attributes",
48
+ "duration_ms",
49
+ "error",
50
+ "name",
51
+ "path",
52
+ "start",
53
+ "started_at",
54
+ "track",
55
+ )
56
+
57
+ def __init__(self, name: str, store, **attributes: Any):
58
+ self.name = str(name).strip("/") or "span"
59
+ self.attributes = dict(attributes)
60
+ self._store = store
61
+ self.path = self.name
62
+ self.track = 0
63
+ self.start = 0.0
64
+ self.started_at = 0.0
65
+ self.duration_ms = 0.0
66
+ self.error: str | None = None
67
+ self._token = None
68
+ self._ended = False
69
+
70
+ # ------------------------------------------------------------------ lifecycle
71
+
72
+ def begin(self) -> Span:
73
+ parent = current_span()
74
+ self.path = f"{parent.path}/{self.name}" if parent else self.name
75
+ # Children share their parent's track, so a tree never straddles lanes
76
+ self.track = parent.track if parent else threading.get_ident()
77
+ self.started_at = time.time()
78
+ self.start = time.perf_counter()
79
+ self._token = _STACK.set((*_STACK.get(), self))
80
+ return self
81
+
82
+ def end(self, error: BaseException | None = None) -> float:
83
+ """Close the span and record it. Returns the duration in milliseconds."""
84
+ if self._ended:
85
+ return self.duration_ms
86
+ self._ended = True
87
+ self.duration_ms = (time.perf_counter() - self.start) * 1000.0
88
+ if error is not None:
89
+ self.error = type(error).__name__
90
+ if self._token is not None:
91
+ try:
92
+ _STACK.reset(self._token)
93
+ except ValueError: # pragma: no cover - ended on another thread/task
94
+ _STACK.set(tuple(s for s in _STACK.get() if s is not self))
95
+ self._record()
96
+ return self.duration_ms
97
+
98
+ def set(self, **attributes: Any) -> Span:
99
+ """Attach attributes; they reach ``spans.jsonl``, not the metrics."""
100
+ self.attributes.update(attributes)
101
+ return self
102
+
103
+ # ------------------------------------------------------------------ context
104
+
105
+ def __enter__(self) -> Span:
106
+ return self
107
+
108
+ def __exit__(self, kind, value, traceback) -> bool:
109
+ self.end(value)
110
+ return False
111
+
112
+ async def __aenter__(self) -> Span:
113
+ return self
114
+
115
+ async def __aexit__(self, kind, value, traceback) -> bool:
116
+ self.end(value)
117
+ return False
118
+
119
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
120
+ state = f"{self.duration_ms:.3f}ms" if self._ended else "open"
121
+ return f"Span({self.path}, {state})"
122
+
123
+ # ------------------------------------------------------------------ output
124
+
125
+ def _record(self):
126
+ if self._store is None:
127
+ return
128
+ metrics = {
129
+ f"{self.path}/{DURATION_SUFFIX}": self.duration_ms,
130
+ f"{self.path}/{COUNT_SUFFIX}": 1,
131
+ }
132
+ record = {
133
+ "name": self.path,
134
+ "depth": self.path.count("/"),
135
+ "track": self.track,
136
+ "start": self.started_at,
137
+ "dur_ms": self.duration_ms,
138
+ }
139
+ if self.attributes:
140
+ record["args"] = self.attributes
141
+ if self.error:
142
+ record["error"] = self.error
143
+ try:
144
+ self._store.record_span(metrics, record)
145
+ except Exception as e: # a measurement must never break the thing measured
146
+ logger.warning(f"Failed to record span {self.path!r}: {e}")
147
+
148
+
149
+ class _NullSpan(Span):
150
+ """What you get without a run: still times, records nothing."""
151
+
152
+ def __init__(self, name: str, **attributes: Any):
153
+ super().__init__(name, None, **attributes)
154
+
155
+
156
+ def start_span(name: str, **attributes: Any) -> Span:
157
+ """Begin a span that you will ``end()`` yourself, possibly in another scope."""
158
+ from .run import current_run
159
+
160
+ run = current_run()
161
+ store = run.history if run is not None else None
162
+ span = Span(name, store, **attributes) if store is not None else _NullSpan(name)
163
+ return span.begin()
164
+
165
+
166
+ class span:
167
+ """Time a region, as a context manager, an async context manager or a decorator.
168
+
169
+ ```python
170
+ with et.span("forward"):
171
+ with et.span("attention"):
172
+ ...
173
+
174
+ @et.span("preprocess")
175
+ def preprocess(batch): ...
176
+
177
+ async with et.span("fetch"):
178
+ await loader.next()
179
+ ```
180
+
181
+ Nested names join with ``/``, so the duration lands on the metric
182
+ ``forward/attention/duration_ms`` and window functions work on it directly.
183
+ """
184
+
185
+ def __init__(self, name: str, **attributes: Any):
186
+ self._name = name
187
+ self._attributes = attributes
188
+ self._span: Span | None = None
189
+
190
+ def __enter__(self) -> Span:
191
+ self._span = start_span(self._name, **self._attributes)
192
+ return self._span
193
+
194
+ def __exit__(self, kind, value, traceback) -> bool:
195
+ if self._span is not None:
196
+ self._span.end(value)
197
+ return False
198
+
199
+ async def __aenter__(self) -> Span:
200
+ return self.__enter__()
201
+
202
+ async def __aexit__(self, kind, value, traceback) -> bool:
203
+ return self.__exit__(kind, value, traceback)
204
+
205
+ def __call__(self, fn):
206
+ import asyncio
207
+
208
+ if asyncio.iscoroutinefunction(fn):
209
+
210
+ @functools.wraps(fn)
211
+ async def async_wrapper(*args, **kwargs):
212
+ current = start_span(self._name, **self._attributes)
213
+ error = None
214
+ try:
215
+ return await fn(*args, **kwargs)
216
+ except BaseException as e:
217
+ error = e
218
+ raise
219
+ finally:
220
+ current.end(error)
221
+
222
+ return async_wrapper
223
+
224
+ @functools.wraps(fn)
225
+ def wrapper(*args, **kwargs):
226
+ current = start_span(self._name, **self._attributes)
227
+ error = None
228
+ try:
229
+ return fn(*args, **kwargs)
230
+ except BaseException as e:
231
+ error = e
232
+ raise
233
+ finally:
234
+ current.end(error)
235
+
236
+ return wrapper
expr_tracker/trace.py ADDED
@@ -0,0 +1,189 @@
1
+ """Export recorded spans as a Chrome Trace, readable by Perfetto and chrome://tracing.
2
+
3
+ Writing the format rather than a viewer means the timeline can sit next to a
4
+ ``torch.profiler`` trace in the same window, which is where the interesting
5
+ question usually is: what were the GPUs doing while the data loader stalled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .history.naming import parse_stream, spans_filename
15
+
16
+ MICROSECONDS = 1_000_000.0
17
+
18
+
19
+ def span_files(run: str | Path, stream: str | list[str] | None = "*") -> list[Path]:
20
+ """Span files of a run: every stream by default, or the ones named."""
21
+ directory = Path(run)
22
+ if directory.is_file():
23
+ return [directory]
24
+ if stream == "*":
25
+ # Default producer first: "spans.data.jsonl" sorts before "spans.jsonl"
26
+ return sorted(
27
+ directory.glob("spans*.jsonl"),
28
+ key=lambda p: (parse_stream(p.name) is not None, p.name),
29
+ )
30
+ wanted = [stream] if isinstance(stream, (str, type(None))) else list(stream)
31
+ found = []
32
+ for name in wanted:
33
+ path = directory / spans_filename(name, rank_aware=False)
34
+ if not path.is_file():
35
+ raise FileNotFoundError(f"No {path.name} under {directory}")
36
+ found.append(path)
37
+ return found
38
+
39
+
40
+ def read_spans(path: Path) -> list[dict]:
41
+ """Decode one span file, skipping anything malformed."""
42
+ if not path.is_file():
43
+ return []
44
+ spans = []
45
+ for line in path.read_text(encoding="utf-8").splitlines():
46
+ if not line.strip():
47
+ continue
48
+ try:
49
+ record = json.loads(line)
50
+ except ValueError:
51
+ continue
52
+ if isinstance(record, dict) and "start" in record and "dur_ms" in record:
53
+ spans.append(record)
54
+ return spans
55
+
56
+
57
+ def _pack(spans: list[dict], indices: list[int], offset: int) -> dict[int, int]:
58
+ """Lay one track's spans onto lanes, each lane a valid stack."""
59
+ order = sorted(
60
+ indices, key=lambda i: (spans[i]["start"], -float(spans[i]["dur_ms"]))
61
+ )
62
+ lanes: list[list[float]] = [] # per lane, the end times currently open
63
+ placement: dict[int, int] = {}
64
+ for index in order:
65
+ start = float(spans[index]["start"])
66
+ end = start + float(spans[index]["dur_ms"]) / 1000.0
67
+ for lane, stack in enumerate(lanes):
68
+ while stack and stack[-1] <= start:
69
+ stack.pop()
70
+ if not stack or end <= stack[-1]: # free, or nests inside the top
71
+ stack.append(end)
72
+ placement[index] = offset + lane
73
+ break
74
+ else:
75
+ lanes.append([end])
76
+ placement[index] = offset + len(lanes) - 1
77
+ return placement
78
+
79
+
80
+ def assign_lanes(spans: list[dict]) -> dict[int, int]:
81
+ """Place spans on lanes so each lane is a valid stack, keyed by list index.
82
+
83
+ Threads come first: work on different threads is unrelated, and letting one
84
+ contain the other would draw a nesting that never happened. Within a thread,
85
+ anything still overlapping -- concurrent asyncio tasks -- gets its own lane,
86
+ because a Chrome Trace track must be properly nested to render at all.
87
+ """
88
+ by_track: dict[Any, list[int]] = {}
89
+ for index, record in enumerate(spans):
90
+ by_track.setdefault(record.get("track", 0), []).append(index)
91
+ placement: dict[int, int] = {}
92
+ offset = 0
93
+ for track in sorted(by_track, key=str):
94
+ lanes = _pack(spans, by_track[track], offset)
95
+ placement.update(lanes)
96
+ offset = max(lanes.values(), default=offset - 1) + 1
97
+ return placement
98
+
99
+
100
+ def build_trace(
101
+ run: str | Path,
102
+ stream: str | list[str] | None = "*",
103
+ *,
104
+ step_range: tuple[int | None, int | None] | None = None,
105
+ ) -> dict[str, Any]:
106
+ """Chrome Trace Event Format for a run's spans, one process per stream."""
107
+ events: list[dict] = []
108
+ origin: float | None = None
109
+ for pid, path in enumerate(span_files(run, stream)):
110
+ spans = read_spans(path)
111
+ if step_range is not None:
112
+ low, high = step_range
113
+ spans = [
114
+ s
115
+ for s in spans
116
+ if (low is None or s.get("_step", 0) >= low)
117
+ and (high is None or s.get("_step", 0) < high)
118
+ ]
119
+ name = parse_stream(path.name) or "default"
120
+ events.append(
121
+ {
122
+ "name": "process_name",
123
+ "ph": "M",
124
+ "pid": pid,
125
+ "tid": 0,
126
+ "args": {"name": name},
127
+ }
128
+ )
129
+ if not spans:
130
+ continue
131
+ lanes = assign_lanes(spans)
132
+ tracks = sorted({s.get("track", 0) for s in spans}, key=str)
133
+ # Label each lane with the thread it came from
134
+ lane_track: dict[int, Any] = {}
135
+ for index, record in enumerate(spans):
136
+ lane_track.setdefault(lanes[index], record.get("track", 0))
137
+ for tid, track in sorted(lane_track.items()):
138
+ label = f"thread {track}" if len(tracks) > 1 else name
139
+ events.append(
140
+ {
141
+ "name": "thread_name",
142
+ "ph": "M",
143
+ "pid": pid,
144
+ "tid": tid,
145
+ "args": {"name": label},
146
+ }
147
+ )
148
+ for index, record in enumerate(spans):
149
+ start = float(record["start"])
150
+ origin = start if origin is None else min(origin, start)
151
+ args = dict(record.get("args") or {})
152
+ args["step"] = record.get("_step")
153
+ if record.get("error"):
154
+ args["error"] = record["error"]
155
+ if len(tracks) > 1:
156
+ args["track"] = record.get("track", 0)
157
+ events.append(
158
+ {
159
+ "name": record.get("name", "span"),
160
+ "cat": name,
161
+ "ph": "X",
162
+ "pid": pid,
163
+ "tid": lanes[index],
164
+ "ts": start * MICROSECONDS,
165
+ "dur": float(record["dur_ms"]) * 1000.0,
166
+ "args": args,
167
+ }
168
+ )
169
+ # Relative timestamps keep the numbers readable and the streams aligned
170
+ if origin is not None:
171
+ for event in events:
172
+ if "ts" in event:
173
+ event["ts"] -= origin * MICROSECONDS
174
+ return {"traceEvents": events, "displayTimeUnit": "ms"}
175
+
176
+
177
+ def write_trace(
178
+ run: str | Path,
179
+ output: str | Path,
180
+ stream: str | list[str] | None = "*",
181
+ *,
182
+ step_range: tuple[int | None, int | None] | None = None,
183
+ ) -> int:
184
+ """Write the trace and return how many spans it holds."""
185
+ trace = build_trace(run, stream, step_range=step_range)
186
+ path = Path(output)
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ path.write_text(json.dumps(trace), encoding="utf-8")
189
+ return sum(1 for event in trace["traceEvents"] if event["ph"] == "X")
expr_tracker/tracker.py CHANGED
@@ -10,6 +10,7 @@ from typing import Literal
10
10
  from .artifacts import Artifact
11
11
  from .history import read_history
12
12
  from .run import Run, current_run, require_run, set_run
13
+ from .run import current_run as current
13
14
 
14
15
  Tracker = Run
15
16
 
@@ -70,9 +71,23 @@ def log(data: dict, step: int | None = None, commit: bool | None = None):
70
71
  require_run().log(data, step=step, commit=commit)
71
72
 
72
73
 
74
+ class _Current:
75
+ """Sentinel: ``stream`` was not given, so use the running stream.
76
+
77
+ ``stream=None`` has to keep meaning the default, unnamed producer.
78
+ """
79
+
80
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
81
+ return "<current stream>"
82
+
83
+
84
+ CURRENT_STREAM = _Current()
85
+
86
+
73
87
  def history(
74
88
  n: int | None = 50,
75
89
  *,
90
+ stream: str | _Current | None = CURRENT_STREAM,
76
91
  output_type: str = "dict",
77
92
  metrics: Sequence[str] | None = None,
78
93
  step_range: tuple[int | None, int | None] | None = None,
@@ -86,25 +101,32 @@ def history(
86
101
 
87
102
  ``output_type="pandas"`` returns a DataFrame (pandas is an optional extra).
88
103
  Passing ``run`` reads that run directory or file offline, without ``init()``.
104
+ ``stream`` selects an independent producer, ``None`` being the default one;
105
+ another process's stream is read from its file, so it reflects what that
106
+ process has flushed. Omit it to read whichever stream this process writes.
89
107
  """
90
- if run is not None:
91
- return read_history(
92
- run,
108
+ active = getattr(current(), "stream", None)
109
+ if run is None and (stream is CURRENT_STREAM or stream == active):
110
+ return require_run().history.get(
93
111
  n,
94
112
  output_type=output_type,
95
113
  metrics=metrics,
96
114
  step_range=step_range,
97
115
  include_meta=include_meta,
116
+ include_open=include_open,
98
117
  fill_missing=fill_missing,
99
118
  dropna=dropna,
100
119
  )
101
- return require_run().history.get(
120
+ if run is None:
121
+ run = require_run().history.log_dir
122
+ return read_history(
123
+ run,
102
124
  n,
125
+ stream=None if stream is CURRENT_STREAM else stream,
103
126
  output_type=output_type,
104
127
  metrics=metrics,
105
128
  step_range=step_range,
106
129
  include_meta=include_meta,
107
- include_open=include_open,
108
130
  fill_missing=fill_missing,
109
131
  dropna=dropna,
110
132
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expr_tracker
3
- Version: 0.2.2
3
+ Version: 0.2.3
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/
@@ -106,6 +106,8 @@ install command; it never crashes a run.
106
106
  | [Alerts](https://hspk.github.io/expr_tracker/guide/alerts/) | An expression DSL with rolling windows, three-valued logic (no false alarms during warm-up), a rule state machine, and a watchdog that catches a hung run. |
107
107
  | [Channels](https://hspk.github.io/expr_tracker/guide/alerts/#channels) | Lark, Slack, DingTalk, WeCom, generic webhook, email — with rate limiting, dedup, retries and per-channel routing. |
108
108
  | [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
109
+ | [Spans](https://hspk.github.io/expr_tracker/guide/spans/) | Time the parts of a step, and their parts. Each duration becomes a metric, so alerts and queries work on it unchanged; `et trace` exports the timeline for Perfetto. |
110
+ | [Streams](https://hspk.github.io/expr_tracker/guide/streams/) | Independent producers — a data worker and a training loop — each with their own step cursor and file inside one run. |
109
111
  | [Distributed](https://hspk.github.io/expr_tracker/guide/distributed/) | Per-rank shards so concurrent appends cannot corrupt step order; only rank 0 alerts by default. |
110
112
  | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
111
113
 
@@ -148,6 +150,9 @@ uv run ruff format src tests
148
150
  | `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
149
151
  | `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
150
152
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
153
+ | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
154
+ | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
155
+ | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
151
156
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
152
157
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
153
158
  | `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
@@ -1,12 +1,14 @@
1
- expr_tracker/__init__.py,sha256=pegVYNdNFlGuiQmIrNjQvSn4xWEUf-esvK_PzwDvr24,1334
1
+ expr_tracker/__init__.py,sha256=wWvM6nZSIlP_pWYcmQGEbMMD9GCBA0hpatui5TNwPHI,1418
2
2
  expr_tracker/_compat.py,sha256=qf8oYIK_lkoLzcPp6wPCYLJtVatCl7ROYRNPwc5F1Pg,981
3
3
  expr_tracker/artifacts.py,sha256=yRVXmuqdZUxiZa0c_CwZi4rBxaItwhu9bZ1x21yP1S0,12560
4
- expr_tracker/cli.py,sha256=YM1tuPX19Y_oNWtCi1DmAY3wyKd-uGILzM1twvv5il4,6305
4
+ expr_tracker/cli.py,sha256=dtp0YsNtGg7T7M-elA511YX8NU_-OJRAwNbs8I1353o,7255
5
5
  expr_tracker/encoders.py,sha256=X5ySQasSrYPsdYCdk7nhpJFGFB9f_8HWQuGOQjTvh9E,11991
6
6
  expr_tracker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- expr_tracker/run.py,sha256=7hHuLaa5Ox-cP891eMuxZ6_Uy9iTaaqMjjshl32kmVk,15744
7
+ expr_tracker/run.py,sha256=nuXhNmepG688X86cI0FMM_hqjyygD1Oi8Ck8-lyHsPA,16664
8
+ expr_tracker/spans.py,sha256=su1M0yxgi51911a6rBPaWhDoVBfIW1vla_okYCW9Z28,7365
8
9
  expr_tracker/summary.py,sha256=lpHSh3pci2Ym6wJhVZwNUJbUp2A64EN_rC7Ym2d7PPI,3150
9
- expr_tracker/tracker.py,sha256=NabstZ2Ysme64RkPooZj-r7AA8Wst49H9au5Q7yTn9o,5073
10
+ expr_tracker/trace.py,sha256=lag-T-5F1LOz4MKsxeMfTW1FJN0KxxznJ48sYT7vX28,6966
11
+ expr_tracker/tracker.py,sha256=hpZB8bFA2GqCbvHEDZkBS9cCzi2JgXeLUNTzJm-wr8c,5921
10
12
  expr_tracker/types.py,sha256=69gFtUaKGl4asLK7nJci9XyTyV0yTIPorB-bcfGrCBA,383
11
13
  expr_tracker/alerts/__init__.py,sha256=dl96OYZh75kV3NLBSA35zNvTFcL6XFMuPoYvRoz7hlQ,8192
12
14
  expr_tracker/alerts/dispatch.py,sha256=k2pDZVJy6ajlQ7kUdvaBBZ_AmdtZ8opaQWjADn_i8uI,11652
@@ -21,15 +23,16 @@ expr_tracker/alerts/expr/lexer.py,sha256=NrvDi-f_8F9V1_vQT-vAGENNA8nauD9IbJH4r21
21
23
  expr_tracker/alerts/expr/nodes.py,sha256=ZsXX4VJDolVQsVoEtIhkDj6QspMz7lTfRYaEZ8F6grg,9896
22
24
  expr_tracker/alerts/expr/parser.py,sha256=heGXAVjCZ1AM0u5-Usld4G_P38N0nUaXMlh8C7CHqjQ,6315
23
25
  expr_tracker/alerts/expr/rule.py,sha256=nO8vzhWyGDxSmSXqJrXOU_Yoy7Tzot3_c2W2bZ4s2Qw,3653
24
- expr_tracker/history/__init__.py,sha256=sppio0jJ2E8WBr3HcZ7UO5vo7GNqQA1rhEgN6wuhQ_o,522
26
+ expr_tracker/history/__init__.py,sha256=oSAamhXglLh1oqIHI0-DqQHlR97yt1gLBWSTlyFbCo0,716
25
27
  expr_tracker/history/codec.py,sha256=iD0TmnmahpRmSRRXt1-lmTPPOzL4TdA4uRQlhMdg49o,3121
26
28
  expr_tracker/history/frame.py,sha256=PFDGqR0oHY94KosSXA7Fu-bphEhZ2yM8cXW9rlZ2IC0,2996
27
- expr_tracker/history/reader.py,sha256=YYrQluS5dopax84ltd5gr6xO4yrygRiOo3emIhjR6Kk,12822
29
+ expr_tracker/history/naming.py,sha256=CT2xDpo0GHYnLyjgRlsPjIX3GAIDEdn4U9I1-cNahHw,2507
30
+ expr_tracker/history/reader.py,sha256=5PY5nbNfX-Cl34jcyYTq2qm8nNgNDTqv7U5Xnb9Rgxw,14155
28
31
  expr_tracker/history/series.py,sha256=cFdUO1FRs5KW0jYsNPzda3tcu645NH1wwfXJitQfXFM,4042
29
- expr_tracker/history/store.py,sha256=Q7F0I_dF2zxHUTtSnzfVCviSeAvW49i3OqLMjoQh1sQ,33072
32
+ expr_tracker/history/store.py,sha256=ZazPIcQ0Aj23DzIBN7B-xiIAk5ej6wfwsUdBYNywHNw,36047
30
33
  expr_tracker/history/writer.py,sha256=q5kNANcZCq5nykvOK5jZ2xc6Z1B4oZxEn3uSXzDCtlY,16096
31
- expr_tracker-0.2.2.dist-info/METADATA,sha256=525p8soaoyMXiBApvXQjcJ5JVTJOb1b0v6lakS6ZB5Y,8388
32
- expr_tracker-0.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
33
- expr_tracker-0.2.2.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
34
- expr_tracker-0.2.2.dist-info/licenses/LICENSE,sha256=ORsYTnF-2SLUaM8FgXlJ-uXuN4ceGACAAU3JZiS8pvs,1069
35
- expr_tracker-0.2.2.dist-info/RECORD,,
34
+ expr_tracker-0.2.3.dist-info/METADATA,sha256=HXNy4hcFe74JnO8dYmnPfoCqt2IJpjWJT34E6eK6yYE,9120
35
+ expr_tracker-0.2.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
36
+ expr_tracker-0.2.3.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
37
+ expr_tracker-0.2.3.dist-info/licenses/LICENSE,sha256=ORsYTnF-2SLUaM8FgXlJ-uXuN4ceGACAAU3JZiS8pvs,1069
38
+ expr_tracker-0.2.3.dist-info/RECORD,,