expr-tracker 0.2.6__tar.gz → 0.2.7__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 (112) hide show
  1. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/PKG-INFO +1 -1
  2. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/distributed.md +6 -0
  3. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/spans.md +8 -6
  4. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/reference/api.md +1 -1
  5. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/spans.py +5 -1
  6. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_distributed.py +22 -0
  7. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_examples.py +1 -1
  8. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_span_plugins.py +46 -13
  9. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/.github/workflows/docs.yaml +0 -0
  10. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/.github/workflows/release.yaml +0 -0
  11. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/.gitignore +0 -0
  12. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/LICENSE +0 -0
  13. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/README.md +0 -0
  14. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/architecture.md +0 -0
  15. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/design.md +0 -0
  16. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/examples.md +0 -0
  17. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/getting-started.md +0 -0
  18. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/alerts.md +0 -0
  19. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/artifacts.md +0 -0
  20. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/backends.md +0 -0
  21. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/cli.md +0 -0
  22. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/history.md +0 -0
  23. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/logging.md +0 -0
  24. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/guide/streams.md +0 -0
  25. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/index.md +0 -0
  26. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/reference/configuration.md +0 -0
  27. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/docs/reference/expressions.md +0 -0
  28. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/README.md +0 -0
  29. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/alert_rules.py +0 -0
  30. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/checkpoints.py +0 -0
  31. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/early_stopping.py +0 -0
  32. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/multiprocess_pipeline.py +0 -0
  33. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/profile_step.py +0 -0
  34. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/examples/quickstart.py +0 -0
  35. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/mkdocs.yml +0 -0
  36. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/pyproject.toml +0 -0
  37. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/__init__.py +0 -0
  38. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/_compat.py +0 -0
  39. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/__init__.py +0 -0
  40. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  41. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/backends/base.py +0 -0
  42. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/backends/cards.py +0 -0
  43. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/dispatch.py +0 -0
  44. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/engine.py +0 -0
  45. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  46. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/eval.py +0 -0
  47. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/functions.py +0 -0
  48. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  49. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  50. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/parser.py +0 -0
  51. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/expr/rule.py +0 -0
  52. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/alerts/models.py +0 -0
  53. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/artifacts.py +0 -0
  54. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/cli.py +0 -0
  55. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/encoders.py +0 -0
  56. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/__init__.py +0 -0
  57. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/codec.py +0 -0
  58. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/frame.py +0 -0
  59. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/naming.py +0 -0
  60. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/reader.py +0 -0
  61. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/series.py +0 -0
  62. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/store.py +0 -0
  63. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/history/writer.py +0 -0
  64. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/plugins.py +0 -0
  65. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/py.typed +0 -0
  66. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/run.py +0 -0
  67. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/summary.py +0 -0
  68. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/trace.py +0 -0
  69. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/tracker.py +0 -0
  70. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/src/expr_tracker/types.py +0 -0
  71. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/conftest.py +0 -0
  72. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_backends.py +0 -0
  73. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_delivery.py +0 -0
  74. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_dispatch.py +0 -0
  75. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_engine.py +0 -0
  76. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_models.py +0 -0
  77. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_alert_routing.py +0 -0
  78. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_artifacts.py +0 -0
  79. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_benchmark.py +0 -0
  80. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_cache.py +0 -0
  81. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_cli.py +0 -0
  82. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_correctness.py +0 -0
  83. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_e2e.py +0 -0
  84. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_expr_builder.py +0 -0
  85. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_expr_eval.py +0 -0
  86. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_expr_functions.py +0 -0
  87. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_expr_parser.py +0 -0
  88. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_expr_properties.py +0 -0
  89. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_failure_modes.py +0 -0
  90. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_features.py +0 -0
  91. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_frame_codec_summary.py +0 -0
  92. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_history.py +0 -0
  93. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_hot_paths.py +0 -0
  94. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_integration.py +0 -0
  95. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_lark.py +0 -0
  96. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_perf.py +0 -0
  97. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_public_surfaces.py +0 -0
  98. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_review_regressions.py +0 -0
  99. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_rule_lifecycle.py +0 -0
  100. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_run_backends.py +0 -0
  101. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_scenarios.py +0 -0
  102. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_spans.py +0 -0
  103. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_step_commit.py +0 -0
  104. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_streams.py +0 -0
  105. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_stress.py +0 -0
  106. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_trace.py +0 -0
  107. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_trackio.py +0 -0
  108. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_value_encoding.py +0 -0
  109. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_wandb.py +0 -0
  110. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_writer_buffer.py +0 -0
  111. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/tests/test_writer_durability.py +0 -0
  112. {expr_tracker-0.2.6 → expr_tracker-0.2.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.2.6
3
+ Version: 0.2.7
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/
@@ -73,6 +73,12 @@ together with `group`, which both backends understand:
73
73
  | rank 2 | `sft-1-rank2` | `sft-1` |
74
74
  | rank 2 of stream `data` | `sft-1-data-rank2` | `sft-1` |
75
75
 
76
+ `rank_aware` does not affect this. It decides whether the *local* file is
77
+ sharded; the remote id is decided by `backend_on_rank`. They stay independent on
78
+ purpose — `rank_aware=False` says you serialise the writes to one file yourself,
79
+ and there is no equivalent lock between two processes' wandb clients, so a rank
80
+ that reports still needs an id of its own.
81
+
76
82
  ## Typical setup
77
83
 
78
84
  ```python
@@ -158,7 +158,7 @@ await asyncio.gather(work("a"), work("b"))
158
158
 
159
159
  ## Printing a span as it runs
160
160
 
161
- Pass `print_fn` and the span announces itself, indented one tab per level:
161
+ Pass `print_fn` and the span announces itself, indented two spaces per level:
162
162
 
163
163
  ```python
164
164
  with et.span("step", print_fn=print):
@@ -169,10 +169,10 @@ with et.span("step", print_fn=print):
169
169
 
170
170
  ```
171
171
  -> step 16:41:38
172
- -> forward 16:41:38
173
- -> attention 16:41:38
174
- <- attention 16:41:38 3.074ms
175
- <- forward 16:41:38 5.481ms
172
+ -> forward 16:41:38
173
+ -> attention 16:41:38
174
+ <- attention 16:41:38 3.074ms
175
+ <- forward 16:41:38 5.481ms
176
176
  <- step 16:41:38 9.641ms
177
177
  ```
178
178
 
@@ -188,7 +188,9 @@ and interleave their lines; each is still indented by its own depth, and the
188
188
  `->` and `<-` markers pair them up.
189
189
 
190
190
  Any callable taking one string works — `print`, `logger.info`, a list's `append`
191
- in tests. It is called on the thread that ran the span, and an exception in it is
191
+ in tests. The indent is spaces rather than a tab so that it survives a log
192
+ prefix: a terminal measures tab stops from the start of the line, so behind
193
+ `... | INFO | ` the first level would collapse to whatever is left of the stop. It is called on the thread that ran the span, and an exception in it is
192
194
  logged and swallowed.
193
195
 
194
196
  Set it for the whole run instead of per span with `span_print_fn`:
@@ -177,7 +177,7 @@ span.metrics # what the plugins measured
177
177
  `<path>/duration_ms`, `<path>/count` and one key per plugin metric to the open
178
178
  row, and appends the full record to `spans.jsonl`.
179
179
 
180
- `print_fn(line)` announces the span's start and end, indented one tab per level.
180
+ `print_fn(line)` announces the span's start and end, indented two spaces per level.
181
181
  `plugins` measure a resource across the span; both are inherited by child spans
182
182
  and default to the run's `span_print_fn` and `span_plugins`.
183
183
 
@@ -26,6 +26,10 @@ _STACK: ContextVar[tuple[Span, ...]] = ContextVar("et_span_stack", default=())
26
26
  DURATION_SUFFIX = "duration_ms"
27
27
  COUNT_SUFFIX = "count"
28
28
  RESERVED_METRICS = frozenset({DURATION_SUFFIX, COUNT_SUFFIX})
29
+ # Spaces, not a tab: a terminal measures tab stops from the start of the line, so
30
+ # behind a log prefix the first level collapses to whatever is left of the stop.
31
+ # Two spaces render the same width wherever the line begins.
32
+ INDENT = " "
29
33
 
30
34
 
31
35
  _WARNED: set[tuple[str, str, str]] = set()
@@ -180,7 +184,7 @@ class Span:
180
184
  def _announce(self, marker: str, duration_ms: float | None):
181
185
  if self.print_fn is None:
182
186
  return
183
- indent = "\t" * self.print_depth
187
+ indent = INDENT * self.print_depth
184
188
  stamp = time.strftime("%H:%M:%S", time.localtime())
185
189
  if duration_ms is None:
186
190
  line = f"{indent}{marker} {self.name} {stamp}"
@@ -456,3 +456,25 @@ def test_streams_and_ranks_both_reach_the_backend_name(rank, tmp_path):
456
456
  call = backend.inits[0]
457
457
  assert call["id"] == "job-data-rank3"
458
458
  assert call["group"] == "job" and call["job_type"] == "data"
459
+
460
+
461
+ def test_the_backend_id_does_not_follow_rank_aware(rank, tmp_path):
462
+ """Local sharding and the remote id are separate concerns.
463
+
464
+ rank_aware=False promises you serialise writes to one file. Two processes'
465
+ wandb clients have no such lock, so a reporting rank still needs its own id.
466
+ """
467
+ backend = RecordingBackend()
468
+ rank(2)
469
+ run = Run(
470
+ project="dist",
471
+ name="job",
472
+ dir=str(tmp_path),
473
+ backends=[backend],
474
+ backend_on_rank=None,
475
+ rank_aware=False,
476
+ )
477
+ run.log({"loss": 1.0})
478
+ run.finish()
479
+ assert backend.inits[0]["id"] == "job-rank2"
480
+ assert run.history.log_fp.name == "metrics.jsonl" # unsharded, as asked
@@ -342,7 +342,7 @@ def test_printing_spans_is_opt_in(profile_step, tmp_path, capsys):
342
342
  assert "-> step" not in capsys.readouterr().out
343
343
  profile_step.main([*argv(**{"--steps": 2, "--dir": tmp_path}), "--print-spans"])
344
344
  printed = capsys.readouterr().out
345
- assert "-> step" in printed and "\t-> data" in printed
345
+ assert "-> step" in printed and "\n -> data" in printed
346
346
 
347
347
 
348
348
  # ------------------------------------------------------------------ early_stopping
@@ -14,6 +14,7 @@ import expr_tracker as et
14
14
  from expr_tracker import plugins as pl
15
15
  from expr_tracker.history.naming import spans_filename
16
16
  from expr_tracker.plugins import CpuTime, GpuStats, TorchMemory, stop_samplers
17
+ from expr_tracker.spans import INDENT
17
18
 
18
19
 
19
20
  @pytest.fixture
@@ -45,6 +46,11 @@ def lines():
45
46
  return []
46
47
 
47
48
 
49
+ def depth_of(line):
50
+ """Nesting depth of a printed line, measured from its leading indent."""
51
+ return (len(line) - len(line.lstrip(" "))) // len(INDENT)
52
+
53
+
48
54
  def span_records(run_obj):
49
55
  path = run_obj.history.log_dir / spans_filename(None, True)
50
56
  if not path.exists():
@@ -71,8 +77,8 @@ def test_indents_one_tab_per_level(run, lines):
71
77
  run()
72
78
  with et.span("a", print_fn=lines.append), et.span("b"), et.span("c"):
73
79
  pass
74
- assert [line.count("\t") for line in lines] == [0, 1, 2, 2, 1, 0]
75
- assert lines[2].startswith("\t\t-> c")
80
+ assert [depth_of(line) for line in lines] == [0, 1, 2, 2, 1, 0]
81
+ assert lines[2].startswith(INDENT * 2 + "-> c")
76
82
 
77
83
 
78
84
  def test_indentation_is_relative_to_where_printing_started(run, lines):
@@ -85,8 +91,8 @@ def test_indentation_is_relative_to_where_printing_started(run, lines):
85
91
  et.span("leaf"),
86
92
  ):
87
93
  pass
88
- assert [line.count("\t") for line in lines] == [0, 1, 1, 0]
89
- assert lines[0] == lines[0].lstrip("\t")
94
+ assert [depth_of(line) for line in lines] == [0, 1, 1, 0]
95
+ assert lines[0] == lines[0].lstrip(" ")
90
96
 
91
97
 
92
98
  def test_an_overriding_child_roots_its_own_indentation(run, lines):
@@ -99,7 +105,7 @@ def test_an_overriding_child_roots_its_own_indentation(run, lines):
99
105
  et.span("d"),
100
106
  ):
101
107
  pass
102
- assert [line.count("\t") for line in other] == [0, 1, 1, 0]
108
+ assert [depth_of(line) for line in other] == [0, 1, 1, 0]
103
109
 
104
110
 
105
111
  def test_the_run_default_still_indents_the_whole_tree(run, lines):
@@ -107,7 +113,7 @@ def test_the_run_default_still_indents_the_whole_tree(run, lines):
107
113
  run(span_print_fn=lines.append)
108
114
  with et.span("a"), et.span("b"), et.span("c"):
109
115
  pass
110
- assert [line.count("\t") for line in lines] == [0, 1, 2, 2, 1, 0]
116
+ assert [depth_of(line) for line in lines] == [0, 1, 2, 2, 1, 0]
111
117
 
112
118
 
113
119
  def test_indentation_keeps_up_with_deep_nesting(run, lines):
@@ -117,7 +123,7 @@ def test_indentation_keeps_up_with_deep_nesting(run, lines):
117
123
  spans += [et.span(f"s{i}").__enter__() for i in range(1, depth)]
118
124
  for sp in reversed(spans):
119
125
  sp.end()
120
- assert [line.count("\t") for line in lines[:depth]] == list(range(depth))
126
+ assert [depth_of(line) for line in lines[:depth]] == list(range(depth))
121
127
 
122
128
 
123
129
  def test_a_new_thread_starts_a_fresh_tree(run, lines):
@@ -133,7 +139,7 @@ def test_a_new_thread_starts_a_fresh_tree(run, lines):
133
139
  thread = threading.Thread(target=work)
134
140
  thread.start()
135
141
  thread.join()
136
- assert [line.count("\t") for line in inner] == [0, 0]
142
+ assert [depth_of(line) for line in inner] == [0, 0]
137
143
 
138
144
 
139
145
  def test_concurrent_tasks_are_each_indented_by_their_own_depth(run, lines):
@@ -151,7 +157,7 @@ def test_concurrent_tasks_are_each_indented_by_their_own_depth(run, lines):
151
157
  by_name = {}
152
158
  for line in lines:
153
159
  parts = line.split()
154
- by_name.setdefault(parts[1], []).append(line.count("\t"))
160
+ by_name.setdefault(parts[1], []).append(depth_of(line))
155
161
  assert by_name["gather"] == [0, 0]
156
162
  assert by_name["a"] == [1, 1] and by_name["b"] == [1, 1]
157
163
  assert by_name["sub"] == [2, 2, 2, 2]
@@ -165,7 +171,7 @@ def test_a_recursive_span_indents_by_recursion_depth(run, lines):
165
171
  return n if n < 2 else fib(n - 1) + fib(n - 2)
166
172
 
167
173
  fib(2)
168
- assert [line.count("\t") for line in lines] == [0, 1, 1, 1, 1, 0]
174
+ assert [depth_of(line) for line in lines] == [0, 1, 1, 1, 1, 0]
169
175
 
170
176
 
171
177
  def test_a_span_ended_out_of_order_does_not_resurrect_itself(run):
@@ -192,7 +198,7 @@ def test_out_of_order_ends_keep_the_indentation_honest(run, lines):
192
198
  inner.end()
193
199
  later = et.start_span("later", print_fn=lines.append)
194
200
  later.end()
195
- assert [line.count("\t") for line in lines] == [0, 1, 0, 1, 0, 0]
201
+ assert [depth_of(line) for line in lines] == [0, 1, 0, 1, 0, 0]
196
202
 
197
203
 
198
204
  def test_a_slashed_name_does_not_fake_a_level(run, lines):
@@ -202,11 +208,38 @@ def test_a_slashed_name_does_not_fake_a_level(run, lines):
202
208
  pass
203
209
  et.log({"loss": 1.0})
204
210
  et.finish()
205
- assert [line.count("\t") for line in lines] == [0, 1, 1, 0]
211
+ assert [depth_of(line) for line in lines] == [0, 1, 1, 0]
206
212
  records = {rec["name"]: rec["depth"] for rec in span_records(r)}
207
213
  assert records == {"data/load": 0, "data/load/decode": 1}
208
214
 
209
215
 
216
+ def test_the_indent_survives_a_log_prefix(run, lines):
217
+ """A tab is measured from the start of the line, so behind a logger prefix
218
+ the first level collapses to whatever is left of the tab stop. Spaces do
219
+ not care where the line begins."""
220
+ run()
221
+ with et.span("a", print_fn=lines.append), et.span("b"), et.span("c"):
222
+ pass
223
+ assert "\t" not in "".join(lines)
224
+ for prefix in ("", "x" * 11, "x" * 71, "x" * 72):
225
+ columns = [_visual_indent(prefix + line) for line in lines]
226
+ relative = [column - len(prefix) for column in columns]
227
+ assert relative == [0, 2, 4, 4, 2, 0], prefix
228
+
229
+
230
+ def _visual_indent(line, tabsize=8):
231
+ """Where the marker lands once a terminal has expanded the tabs."""
232
+ column = 0
233
+ for index, char in enumerate(line):
234
+ if char == "\t":
235
+ column += tabsize - (column % tabsize)
236
+ elif line.startswith(("-> ", "<- "), index):
237
+ return column
238
+ else:
239
+ column += 1
240
+ return column
241
+
242
+
210
243
  def test_children_inherit_the_handler(run, lines):
211
244
  run()
212
245
  with et.span("outer", print_fn=lines.append), et.span("inner"):
@@ -351,7 +384,7 @@ def test_sibling_spans_restart_the_indentation(run, lines):
351
384
  pass
352
385
  with et.span("two"):
353
386
  pass
354
- assert [line.count("\t") for line in lines] == [0, 1, 1, 1, 1, 0]
387
+ assert [depth_of(line) for line in lines] == [0, 1, 1, 1, 1, 0]
355
388
 
356
389
 
357
390
  # --------------------------------------------------------------------------- #
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes