topiclayers 0.2.0__tar.gz → 0.3.0__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 (26) hide show
  1. {topiclayers-0.2.0 → topiclayers-0.3.0}/PKG-INFO +27 -12
  2. {topiclayers-0.2.0 → topiclayers-0.3.0}/README.md +26 -11
  3. {topiclayers-0.2.0 → topiclayers-0.3.0}/pyproject.toml +1 -1
  4. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/__init__.py +1 -1
  5. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/cli.py +42 -15
  6. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/config.py +17 -0
  7. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/data.py +8 -7
  8. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/network.py +4 -2
  9. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/topic.py +31 -21
  10. topiclayers-0.3.0/src/topiclayers/examples/generic/posts.csv +8 -0
  11. topiclayers-0.3.0/src/topiclayers/examples/generic.yml +15 -0
  12. topiclayers-0.3.0/src/topiclayers/examples/template_csv.yml +49 -0
  13. topiclayers-0.3.0/src/topiclayers/examples/template_twitter.yml +90 -0
  14. topiclayers-0.3.0/src/topiclayers/examples/twitter/toy.json +1 -0
  15. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/status.py +142 -5
  16. {topiclayers-0.2.0 → topiclayers-0.3.0}/LICENSE +0 -0
  17. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/__init__.py +0 -0
  18. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/csv.py +0 -0
  19. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/registry.py +0 -0
  20. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/twitter.py +0 -0
  21. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/__init__.py +0 -0
  22. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/manifest.py +0 -0
  23. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/schema.py +0 -0
  24. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/support/__init__.py +0 -0
  25. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/support/cache.py +0 -0
  26. {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/support/repro.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topiclayers
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -43,6 +43,10 @@ Description-Content-Type: text/markdown
43
43
  ```bash
44
44
  # Requires Python 3.12
45
45
  pip install topiclayers
46
+
47
+ # copy runnable examples (configs + toy data) into ./examples/
48
+ topiclayers init
49
+
46
50
  topiclayers run examples/generic.yml
47
51
  ```
48
52
 
@@ -99,7 +103,7 @@ Stage ETA: ~35m 20s
99
103
  Total elapsed: 21m 05s
100
104
  ```
101
105
 
102
- Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
106
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/stage_timings.json`. While running, the terminal itself shows a live progress line (spinner, counter, ETA, elapsed time) that keeps ticking even in stages without a counter, plus a per-stage duration summary when the run finishes.
103
107
 
104
108
  ## Topic labeling with an LLM (optional)
105
109
 
@@ -161,7 +165,8 @@ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repo
161
165
  | Symptom | Likely cause | Fix |
162
166
  |---------|-------------|-----|
163
167
  | "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
164
- | "Module not found" | topiclayers not installed | `pip install -e .` |
168
+ | "Module not found" | topiclayers not installed | `pip install topiclayers` |
169
+ | `topiclayers: command not found` | installed in a venv that isn't active | Activate the venv (`source .venv/bin/activate`) or use `python -m topiclayers.cli`; on install with `--user` add `~/.local/bin` to PATH |
165
170
  | "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
166
171
  | "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
167
172
  | "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
@@ -175,12 +180,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
175
180
  <output_dir>/
176
181
  ├── run_manifest.json # Reproducibility metadata
177
182
  ├── run_status.json # Live stage/ETA tracking (while running)
178
- ├── cache/
179
- │ └── data/
180
- │ ├── tweets_<name>.pkl/.csv # Full tweet table
181
- │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
182
- │ └── manifest_<name>.json # Cache validity key
183
- │ └── stage_timings.json # Per-stage durations for ETA prediction
183
+ ├── stage_timings.json # Per-stage durations for ETA prediction
184
184
  └── networks/
185
185
  ├── <name>_retweet.gml # Single-layer retweet network
186
186
  ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
@@ -189,13 +189,28 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
189
189
  └── <name>__prj_<topic>.gml # Per-topic projected networks
190
190
  ```
191
191
 
192
+ Reusable artifacts (processed data, embeddings, the BERTopic model) go to the
193
+ **cache**, not the output dir. By default the cache lives in a `cache/` folder
194
+ **next to the input data file**, so it persists across runs regardless of
195
+ `output_dir` and can be shared between configs (files are prefixed with the
196
+ dataset `name`). Override the location with `cache_dir:` in the config:
197
+
198
+ ```
199
+ <cache_dir>/
200
+ ├── data/
201
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
202
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
203
+ │ └── manifest_<name>.json # Cache validity key
204
+ └── tm/<embedder_name>/
205
+ ├── embeddings_<name>.pkl # Document embeddings
206
+ ├── topics_<name>.csv # Topic table (with LLM labels)
207
+ └── model_<name>/ # BERTopic safetensors dir
208
+ ```
209
+
192
210
  ## Requirements
193
211
 
194
212
  - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
195
213
  - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
196
214
 
197
- ## Planned: JOSS software paper
198
215
 
199
- Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
200
- a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
201
216
 
@@ -7,6 +7,10 @@
7
7
  ```bash
8
8
  # Requires Python 3.12
9
9
  pip install topiclayers
10
+
11
+ # copy runnable examples (configs + toy data) into ./examples/
12
+ topiclayers init
13
+
10
14
  topiclayers run examples/generic.yml
11
15
  ```
12
16
 
@@ -63,7 +67,7 @@ Stage ETA: ~35m 20s
63
67
  Total elapsed: 21m 05s
64
68
  ```
65
69
 
66
- Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
70
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/stage_timings.json`. While running, the terminal itself shows a live progress line (spinner, counter, ETA, elapsed time) that keeps ticking even in stages without a counter, plus a per-stage duration summary when the run finishes.
67
71
 
68
72
  ## Topic labeling with an LLM (optional)
69
73
 
@@ -125,7 +129,8 @@ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repo
125
129
  | Symptom | Likely cause | Fix |
126
130
  |---------|-------------|-----|
127
131
  | "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
128
- | "Module not found" | topiclayers not installed | `pip install -e .` |
132
+ | "Module not found" | topiclayers not installed | `pip install topiclayers` |
133
+ | `topiclayers: command not found` | installed in a venv that isn't active | Activate the venv (`source .venv/bin/activate`) or use `python -m topiclayers.cli`; on install with `--user` add `~/.local/bin` to PATH |
129
134
  | "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
130
135
  | "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
131
136
  | "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
@@ -139,12 +144,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
139
144
  <output_dir>/
140
145
  ├── run_manifest.json # Reproducibility metadata
141
146
  ├── run_status.json # Live stage/ETA tracking (while running)
142
- ├── cache/
143
- │ └── data/
144
- │ ├── tweets_<name>.pkl/.csv # Full tweet table
145
- │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
146
- │ └── manifest_<name>.json # Cache validity key
147
- │ └── stage_timings.json # Per-stage durations for ETA prediction
147
+ ├── stage_timings.json # Per-stage durations for ETA prediction
148
148
  └── networks/
149
149
  ├── <name>_retweet.gml # Single-layer retweet network
150
150
  ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
@@ -153,12 +153,27 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
153
153
  └── <name>__prj_<topic>.gml # Per-topic projected networks
154
154
  ```
155
155
 
156
+ Reusable artifacts (processed data, embeddings, the BERTopic model) go to the
157
+ **cache**, not the output dir. By default the cache lives in a `cache/` folder
158
+ **next to the input data file**, so it persists across runs regardless of
159
+ `output_dir` and can be shared between configs (files are prefixed with the
160
+ dataset `name`). Override the location with `cache_dir:` in the config:
161
+
162
+ ```
163
+ <cache_dir>/
164
+ ├── data/
165
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
166
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
167
+ │ └── manifest_<name>.json # Cache validity key
168
+ └── tm/<embedder_name>/
169
+ ├── embeddings_<name>.pkl # Document embeddings
170
+ ├── topics_<name>.csv # Topic table (with LLM labels)
171
+ └── model_<name>/ # BERTopic safetensors dir
172
+ ```
173
+
156
174
  ## Requirements
157
175
 
158
176
  - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
159
177
  - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
160
178
 
161
- ## Planned: JOSS software paper
162
179
 
163
- Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
164
- a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "topiclayers"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly"
5
5
  authors = ["alessiogandelli <alessiogandelli99@gmail.com>"]
6
6
  readme = "README.md"
@@ -1,3 +1,3 @@
1
1
  """topiclayers — from posts to topical multilayer networks."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -45,14 +45,16 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
45
45
 
46
46
  status.stage("load")
47
47
  fmt, loader = resolve_adapter(input_path)
48
- print(f"Loading posts via '{fmt}' adapter from {input_path}")
48
+ status.log(f"Loading posts via '{fmt}' adapter from {input_path}")
49
49
  posts = loader(input_path)
50
- print(f" {len(posts)} posts loaded")
50
+ status.log(f" {len(posts)} posts loaded")
51
51
 
52
52
  row_counts = {"raw_posts": len(posts)}
53
53
  tm = None
54
54
 
55
- cache_dir = config.output_dir / "cache"
55
+ cache_dir = config.cache_dir or (input_path.parent / "cache")
56
+ cache_dir.mkdir(parents=True, exist_ok=True)
57
+ status.log(f"Cache: {cache_dir.resolve()}")
56
58
 
57
59
  status.stage("data", total=len(posts))
58
60
  ds = Dataset(posts, n_cop=config.name, file_user=config.input.users)
@@ -60,11 +62,11 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
60
62
  row_counts["tweets"] = len(ds.df_tweets)
61
63
  row_counts["original"] = len(ds.df_original)
62
64
  row_counts["retweets"] = len(ds.df_retweets)
63
- print(f" {len(ds.df_original)} original, {len(ds.df_retweets)} retweets, "
64
- f"{len(ds.df_quotes)} quotes, {len(ds.df_reply)} replies")
65
+ status.log(f" {len(ds.df_original)} original, {len(ds.df_retweets)} retweets, "
66
+ f"{len(ds.df_quotes)} quotes, {len(ds.df_reply)} replies")
65
67
 
66
68
  if len(ds.df_original) == 0:
67
- print(" No original posts found — skipping topic modeling. All retweets will be assigned topic -1.")
69
+ status.log(" No original posts found — skipping topic modeling. All retweets will be assigned topic -1.")
68
70
  df_labeled = ds.df_original.copy()
69
71
  df_labeled["topic"] = -1
70
72
  df_labeled["topic_prob"] = None
@@ -88,15 +90,15 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
88
90
  umap_init=config.topic_model.umap_init,
89
91
  )
90
92
  except RuntimeError as e:
91
- print(f" Topic modeling failed: {e}")
92
- print(" Assigning topic -1 to all originals and continuing.")
93
+ status.log(f" Topic modeling failed: {e}")
94
+ status.log(" Assigning topic -1 to all originals and continuing.")
93
95
  df_labeled = ds.df_original.copy()
94
96
  df_labeled["topic"] = -1
95
97
  df_labeled["topic_prob"] = None
96
98
  row_counts["labeled_originals"] = len(df_labeled)
97
99
  topic_counts = df_labeled["topic"].value_counts().to_dict()
98
100
  topic_dist = {str(k): int(v) for k, v in topic_counts.items()}
99
- print(f" {len(topic_dist)} topics found (including -1 outlier)")
101
+ status.log(f" {len(topic_dist)} topics found (including -1 outlier)")
100
102
 
101
103
  status.stage("label")
102
104
  if config.topic_model.label_model and tm is not None and tm.model is not None:
@@ -104,8 +106,9 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
104
106
  model=config.topic_model.label_model,
105
107
  api_base=config.topic_model.label_api_base,
106
108
  label_context=config.topic_model.label_context,
109
+ reporter=status,
107
110
  )
108
- df_labeled = ds.update_df(df_labeled)
111
+ df_labeled = ds.update_df(df_labeled, reporter=status)
109
112
  ds._save_labeled_dataframe(cache_dir)
110
113
  row_counts["retweets_labeled"] = len(df_labeled)
111
114
 
@@ -120,7 +123,7 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
120
123
  try:
121
124
  nw.create_retweet_ml(reporter=status)
122
125
  except ValueError as e:
123
- print(f" Skipping multilayer network: {e}")
126
+ status.log(f" Skipping multilayer network: {e}")
124
127
  elif network_type == "retweet":
125
128
  nw.create_retweet_network(reporter=status)
126
129
  elif network_type == "ttn":
@@ -130,11 +133,11 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
130
133
  try:
131
134
  nw.create_retweet_ml(reporter=status)
132
135
  except ValueError as e:
133
- print(f" Skipping multilayer network: {e}")
136
+ status.log(f" Skipping multilayer network: {e}")
134
137
  nw.create_ttnetwork(project=config.network.project_ttn)
135
138
  else:
136
- print(f"Unknown network type: '{network_type}'. "
137
- f"Use one of: multilayer_repost, retweet, ttn, all.", file=sys.stderr)
139
+ status.log(f"Unknown network type: '{network_type}'. "
140
+ f"Use one of: multilayer_repost, retweet, ttn, all.")
138
141
  sys.exit(1)
139
142
 
140
143
  status.stage("manifest")
@@ -167,13 +170,37 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
167
170
  print(f"\nDone. Output in {config.output_dir.resolve()}")
168
171
  print(f" Run manifest: {config.output_dir / 'run_manifest.json'}")
169
172
 
173
+ def _cmd_init(dest: Path) -> None:
174
+ import shutil
175
+ from importlib.resources import as_file, files
176
+
177
+ if dest.exists():
178
+ print(f"Directory '{dest}' already exists — remove it or pass another path.",
179
+ file=sys.stderr)
180
+ sys.exit(1)
181
+
182
+ with as_file(files("topiclayers") / "examples") as bundled:
183
+ shutil.copytree(bundled, dest)
184
+
185
+ print(f"Examples copied to {dest.resolve()}")
186
+ print("Try it:")
187
+ print(f" topiclayers run {dest / 'generic.yml'}")
188
+
170
189
 
171
190
  def main() -> None:
172
- if len(sys.argv) < 3 or sys.argv[1] not in ("run", "status"):
191
+ if len(sys.argv) < 2 or (
192
+ sys.argv[1] not in ("run", "status", "init")
193
+ or (sys.argv[1] != "init" and len(sys.argv) < 3)
194
+ ):
173
195
  print("Usage: topiclayers run <config.yaml>", file=sys.stderr)
174
196
  print(" topiclayers status <output_dir>", file=sys.stderr)
197
+ print(" topiclayers init [dest=./examples]", file=sys.stderr)
175
198
  sys.exit(1)
176
199
 
200
+ if sys.argv[1] == "init":
201
+ _cmd_init(Path(sys.argv[2]) if len(sys.argv) > 2 else Path("examples"))
202
+ return
203
+
177
204
  if sys.argv[1] == "status":
178
205
  from topiclayers.status import print_status
179
206
 
@@ -57,6 +57,12 @@ class RunConfig:
57
57
  topic_model: TopicModelConfig = field(default_factory=TopicModelConfig)
58
58
  network: NetworkConfig = field(default_factory=NetworkConfig)
59
59
  output_dir: Path = Path("./out")
60
+ # Where reusable artifacts (processed data, embeddings, models) live.
61
+ # Defaults to a 'cache/' folder next to the input data, so the cache
62
+ # survives output_dir changes and is shared across runs. Independent
63
+ # datasets can share one cache dir: all cache files are prefixed with
64
+ # the dataset `name`.
65
+ cache_dir: Optional[Path] = None
60
66
 
61
67
  @classmethod
62
68
  def from_yaml(cls, path: Path) -> "RunConfig":
@@ -75,10 +81,21 @@ class RunConfig:
75
81
  p = (path.parent / p).resolve()
76
82
  input_raw[key] = p
77
83
 
84
+ cache_dir = raw.get("cache_dir")
85
+ if cache_dir:
86
+ cache_dir = Path(cache_dir)
87
+ if not cache_dir.is_absolute():
88
+ cache_dir = (path.parent / cache_dir).resolve()
89
+ else:
90
+ # default: keep the cache next to the input data
91
+ posts = input_raw.get("posts")
92
+ cache_dir = posts.parent / "cache" if posts else Path("./cache")
93
+
78
94
  return cls(
79
95
  input=InputConfig(**input_raw),
80
96
  name=raw.get("name", "default"),
81
97
  topic_model=TopicModelConfig(**raw.get("topic_model", {})),
82
98
  network=NetworkConfig(**raw.get("network", {})),
83
99
  output_dir=Path(raw.get("output_dir", "./out")),
100
+ cache_dir=cache_dir,
84
101
  )
@@ -10,6 +10,7 @@ from typing import Optional
10
10
  import pandas as pd
11
11
 
12
12
  from topiclayers.support.cache import cache_key
13
+ from topiclayers.status import log_via
13
14
  from topiclayers.schema import InteractionType, Post
14
15
 
15
16
  _INTERACTION_REVERSE_MAP = {
@@ -82,8 +83,8 @@ class Dataset:
82
83
  self.df_users = pd.read_pickle(users_pkl)
83
84
  else:
84
85
  if tweets_pkl.exists():
85
- print("Cache key mismatch — regenerating data cache.")
86
- users_dict = self._load_user_dict() if self.file_user is not None else {}
86
+ log_via(reporter, "Cache key mismatch — regenerating data cache.")
87
+ users_dict = self._load_user_dict(reporter=reporter) if self.file_user is not None else {}
87
88
  tweets_dict = {}
88
89
  for i, post in enumerate(self.posts):
89
90
  if reporter is not None:
@@ -135,13 +136,13 @@ class Dataset:
135
136
 
136
137
  self._create_dataframes()
137
138
 
138
- def _load_user_dict(self) -> dict:
139
+ def _load_user_dict(self, reporter=None) -> dict:
139
140
  import json
140
141
 
141
142
  if self.file_user is None:
142
143
  return {}
143
144
  if not self.file_user.exists():
144
- print(f"User file not found: {self.file_user} — skipping user metadata.")
145
+ log_via(reporter, f"User file not found: {self.file_user} — skipping user metadata.")
145
146
  return {}
146
147
 
147
148
  users = {}
@@ -167,7 +168,7 @@ class Dataset:
167
168
  self.df_quotes = df[df["referenced_type"] == "quoted"].copy()
168
169
  self.df_reply = df[df["referenced_type"] == "replied_to"].copy()
169
170
 
170
- def update_df(self, df_labeled: pd.DataFrame) -> pd.DataFrame:
171
+ def update_df(self, df_labeled: pd.DataFrame, reporter=None) -> pd.DataFrame:
171
172
  if "id" in df_labeled.columns and df_labeled.index.name != "id":
172
173
  df_labeled = df_labeled.set_index("id")
173
174
 
@@ -197,9 +198,9 @@ class Dataset:
197
198
  resolved[idx] = val
198
199
 
199
200
  self.df_retweets_labeled["topic"] = self.df_retweets_labeled.index.map(resolved)
200
- print(f"Unresolved references: {unresolved_count}")
201
+ log_via(reporter, f"Unresolved references: {unresolved_count}")
201
202
  if unresolved_count > 0:
202
- print(f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
203
+ log_via(reporter, f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
203
204
 
204
205
  self.df_retweets_labeled = self.df_retweets_labeled[
205
206
  self.df_retweets_labeled["topic"].apply(lambda x: not isinstance(x, str))
@@ -5,6 +5,8 @@ from pathlib import Path
5
5
  import networkx as nx
6
6
  import pandas as pd
7
7
 
8
+ from topiclayers.status import log_via
9
+
8
10
 
9
11
  class NetworkCreator:
10
12
  def __init__(self, df: pd.DataFrame, name: str, output_dir: Path):
@@ -25,7 +27,7 @@ class NetworkCreator:
25
27
  )
26
28
 
27
29
  if missing_refs:
28
- print(f" {missing_refs} retweets reference tweets not in dataset — skipped.")
30
+ log_via(reporter, f" {missing_refs} retweets reference tweets not in dataset — skipped.")
29
31
 
30
32
  self._save_graph(G, "retweet")
31
33
  return G
@@ -101,7 +103,7 @@ class NetworkCreator:
101
103
  ml.add_nx_layer(ml_network, G, str(topic))
102
104
 
103
105
  if dropped_edges_total:
104
- print(f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
106
+ log_via(reporter, f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
105
107
 
106
108
  self._save_multilayer(ml_network)
107
109
  self.ml_network = ml_network
@@ -11,6 +11,7 @@ import numpy as np
11
11
  import pandas as pd
12
12
 
13
13
  from topiclayers.support.cache import cache_key
14
+ from topiclayers.status import log_via
14
15
 
15
16
 
16
17
  class TopicModeler:
@@ -77,7 +78,7 @@ class TopicModeler:
77
78
  except Exception:
78
79
  pass
79
80
  if cache_valid:
80
- print("using cached topics")
81
+ log_via(reporter, "using cached topics")
81
82
  self.df = pd.read_pickle(self.df_labeled_path)
82
83
  from bertopic import BERTopic
83
84
  self.model = BERTopic.load(self.model_path)
@@ -85,7 +86,7 @@ class TopicModeler:
85
86
  self.docs = self.df["new_text"].tolist()
86
87
  return self.df
87
88
 
88
- print("running topic modeling")
89
+ log_via(reporter, "running topic modeling")
89
90
  docs = self._preprocess()
90
91
 
91
92
  if self.embeddings_path.exists():
@@ -101,7 +102,7 @@ class TopicModeler:
101
102
  except Exception:
102
103
  pass
103
104
  if use_cached:
104
- print(" loading embeddings from cache")
105
+ log_via(reporter, " loading embeddings from cache")
105
106
  with open(self.embeddings_path, "rb") as f:
106
107
  self.embeddings = pickle.load(f)
107
108
  else:
@@ -214,6 +215,11 @@ class TopicModeler:
214
215
  repro_mode: str = "strict",
215
216
  umap_init: str = "pca",
216
217
  ) -> None:
218
+ # mark the stage before importing: torch/umap/bertopic imports take a
219
+ # while and would otherwise be billed to the previous stage
220
+ if reporter is not None:
221
+ reporter.stage("topic_model")
222
+
217
223
  import random
218
224
 
219
225
  from umap import UMAP
@@ -222,9 +228,6 @@ class TopicModeler:
222
228
  from bertopic import BERTopic
223
229
  from bertopic.vectorizers import ClassTfidfTransformer
224
230
 
225
- if reporter is not None:
226
- reporter.stage("topic_model")
227
-
228
231
  np.random.seed(random_state)
229
232
  random.seed(random_state)
230
233
  try:
@@ -293,7 +296,9 @@ class TopicModeler:
293
296
  # large corpora); the default computes the probability of the
294
297
  # assigned topic only — topics themselves are identical
295
298
  calculate_probabilities=exact_probabilities,
296
- verbose=True,
299
+ # False: BERTopic's per-step INFO logging would clobber the live
300
+ # progress line; our reporter provides the progress feedback
301
+ verbose=False,
297
302
  )
298
303
 
299
304
  try:
@@ -350,7 +355,7 @@ class TopicModeler:
350
355
  def _save_qdrant(self, docs: list[str], reporter=None) -> None:
351
356
  qdrant_url = os.getenv("QDRANT_URL")
352
357
  if not qdrant_url:
353
- print("Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
358
+ log_via(reporter, "Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
354
359
  return
355
360
 
356
361
  try:
@@ -386,8 +391,8 @@ class TopicModeler:
386
391
  if reporter is not None:
387
392
  reporter.update(len(points), total=len(points))
388
393
  except Exception as e:
389
- print(f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
390
- print("The pipeline continues — networks and topic labels are unaffected.")
394
+ log_via(reporter, f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
395
+ log_via(reporter, "The pipeline continues — networks and topic labels are unaffected.")
391
396
 
392
397
  def _save_files(self) -> None:
393
398
  self.df_labeled_path.parent.mkdir(parents=True, exist_ok=True)
@@ -409,6 +414,7 @@ class TopicModeler:
409
414
  batch_size: int = 40,
410
415
  label_context: str = "",
411
416
  max_topics: int = 0,
417
+ reporter=None,
412
418
  ) -> dict[int, str]:
413
419
  """Label topics with an LLM, batching many topics per request.
414
420
 
@@ -443,18 +449,19 @@ class TopicModeler:
443
449
  api_base = api_base or os.getenv("LABEL_API_BASE", "")
444
450
 
445
451
  if self.model is None:
446
- print(" No fitted topic model — skipping LLM labeling.")
452
+ log_via(reporter, " No fitted topic model — skipping LLM labeling.")
447
453
  return {}
448
454
 
449
455
  topic_info = self.model.get_topic_info()
450
456
  if "LLM_label" in topic_info.columns and not overwrite:
451
- print(" LLM labels already present — skipping (use overwrite=True).")
457
+ log_via(reporter, " LLM labels already present — skipping (use overwrite=True).")
452
458
  return {}
453
459
 
454
460
  try:
455
461
  import litellm # noqa: F401
456
462
  except ImportError:
457
- print(
463
+ log_via(
464
+ reporter,
458
465
  " LLM labeling skipped: 'litellm' is not installed.\n"
459
466
  " Install it with: pip install 'topiclayers[labeling]'\n"
460
467
  " Topics keep their c-TF-IDF keyword labels."
@@ -470,7 +477,8 @@ class TopicModeler:
470
477
  conn.getresponse().read()
471
478
  conn.close()
472
479
  except (OSError, socket.timeout):
473
- print(
480
+ log_via(
481
+ reporter,
474
482
  f" LLM labeling skipped: Ollama not reachable at {host}:{port}.\n"
475
483
  f" Start it with: ollama serve (then pull the model:\n"
476
484
  f" ollama pull {model.split('/', 1)[1]})\n"
@@ -486,7 +494,8 @@ class TopicModeler:
486
494
  or os.getenv("OPENAI_API_KEY")
487
495
  )
488
496
  if not api_key:
489
- print(
497
+ log_via(
498
+ reporter,
490
499
  " LLM labeling skipped: custom api_base set but no API key found.\n"
491
500
  " Set LABEL_API_KEY (or OPENAI_LIKE_API_KEY / OPENAI_API_KEY) in .env."
492
501
  )
@@ -515,12 +524,12 @@ class TopicModeler:
515
524
  entries.append((tid, keywords, docs_str))
516
525
 
517
526
  if not entries:
518
- print(" No non-outlier topics to label.")
527
+ log_via(reporter, " No non-outlier topics to label.")
519
528
  return {}
520
529
 
521
530
  if max_topics and max_topics < len(entries):
522
531
  entries = entries[:max_topics] # topic_info is sorted by size
523
- print(f" Preview mode: labeling only the {max_topics} largest topics.")
532
+ log_via(reporter, f" Preview mode: labeling only the {max_topics} largest topics.")
524
533
 
525
534
  system_msg = (
526
535
  "You annotate discussion topics from social media.\n"
@@ -577,16 +586,17 @@ class TopicModeler:
577
586
  _load_openai_like_model(model, api_base, generator_kwargs.get("api_key", ""))
578
587
  continue
579
588
  if attempt == 1:
580
- print(f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
589
+ log_via(reporter, f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
581
590
  continue
582
591
  failed += 1
583
- print(
592
+ log_via(
593
+ reporter,
584
594
  f" Batch {i // batch_size + 1}/{n_chunks} failed (non-fatal): "
585
595
  f"{type(e).__name__}: {str(e)[:120]} — those topics keep keyword labels."
586
596
  )
587
597
 
588
598
  if not llm_labels:
589
- print(" No labels generated — topics keep their c-TF-IDF keyword labels.")
599
+ log_via(reporter, " No labels generated — topics keep their c-TF-IDF keyword labels.")
590
600
  return {}
591
601
 
592
602
  # expose labels as an aspect column named LLM_label in get_topic_info()
@@ -609,7 +619,7 @@ class TopicModeler:
609
619
  info.to_csv(self.topic_path)
610
620
  ok = len(llm_labels)
611
621
  suffix = f", {failed} batch(es) failed" if failed else ""
612
- print(f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
622
+ log_via(reporter, f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
613
623
  return labels
614
624
 
615
625
 
@@ -0,0 +1,8 @@
1
+ post_id,user_id,text,created_at,lang,interaction_type,target_post_id
2
+ t1,a,tweet1,2022-01-01,en,original,
3
+ t2,b,tweet2,2022-01-02,en,repost,t1
4
+ t3,c,tweet3,2022-01-03,en,repost,t2
5
+ t4,c,tweet4,2022-01-04,en,original,
6
+ t5,a,tweet5,2022-01-05,en,repost,t7
7
+ t6,d,tweet6,2022-01-06,en,repost,t7
8
+ t7,d,tweet7,2022-01-07,en,original,
@@ -0,0 +1,15 @@
1
+ input:
2
+ format: csv_posts
3
+ # paths are resolved relative to this config file's directory
4
+ posts: generic/posts.csv
5
+
6
+ name: generic_toy
7
+
8
+ topic_model:
9
+ embedder: all-MiniLM-L6-v2
10
+ min_topic_size: 2
11
+
12
+ network:
13
+ type: multilayer_repost
14
+
15
+ output_dir: ./out/generic_toy
@@ -0,0 +1,49 @@
1
+ # ============================================================
2
+ # TEMPLATE — Generic CSV/Parquet posts (any platform)
3
+ # Copy this file, rename it, adjust the values below.
4
+ # Run: topiclayers run examples/<your_config>.yml
5
+ # Paths in `input` are resolved relative to THIS file's directory.
6
+ # ============================================================
7
+
8
+ input:
9
+ format: csv_posts # or parquet_posts
10
+ posts: generic/posts.csv # required columns: post_id, user_id, text
11
+ # optional columns: created_at, lang,
12
+ # interaction_type ("original"|"repost"|"quote"|"reply"),
13
+ # target_post_id, mentions (semicolon-separated),
14
+ # any extra_* column is carried through as metadata
15
+ # users: path/to/users.csv # optional user metadata file
16
+
17
+ name: my_dataset # prefixes outputs and cache keys; changing it starts
18
+ # a fresh cache
19
+
20
+ topic_model:
21
+ embedder: all-MiniLM-L6-v2 # sentence-transformers model, or OpenAI
22
+ # "text-embedding-3-small"/"-large" (needs
23
+ # OPENAI_API_KEY in .env)
24
+ min_topic_size: 10 # small datasets need small values; start low and raise
25
+ # until topics look coherent
26
+ nr_topics: auto # "auto", or an int to force reduction after fit
27
+ random_state: 42
28
+ umap_n_neighbors: 15
29
+ umap_n_components: 5
30
+ exact_probabilities: false # true = full membership matrix (slow on big corpora)
31
+ repro_mode: fast # "fast" = parallel, non-reproducible;
32
+ # "strict" = seeded single-threaded, bit-reproducible
33
+ umap_init: pca # default; ~20x faster than spectral, deterministic
34
+ # when seeded (docs/UMAP_INIT_BENCHMARK.md)
35
+ label_model: ollama/qwen2.5:7b # optional LLM topic labeling (LiteLLM string);
36
+ # "" disables. See template_twitter.yml for setup
37
+
38
+ network:
39
+ type: retweet # for CSV data usually "retweet" (or "multilayer_repost"
40
+ # / "ttn" / "all"); requires target_post_id +
41
+ # interaction_type=repost rows to build edges
42
+ project_ttn: true
43
+
44
+ output_dir: ./out/my_dataset # research outputs only: networks/, run_manifest.json,
45
+ # run_status.json, stage_timings.json
46
+
47
+ # cache_dir: ./cache # optional — where reusable artifacts live (processed
48
+ # data, embeddings, topic model). Defaults to a
49
+ # 'cache/' folder next to the input data file.
@@ -0,0 +1,90 @@
1
+ # ============================================================
2
+ # TEMPLATE — Twitter/X JSONL input (Academic API dump)
3
+ # Copy this file, rename it, adjust the values below.
4
+ # Run: topiclayers run examples/<your_config>.yml
5
+ # Status from another terminal while running:
6
+ # topiclayers status <output_dir>
7
+ # Paths in `input` are resolved relative to THIS file's directory.
8
+ # ============================================================
9
+
10
+ input:
11
+ format: twitter_jsonl # one JSON tweet per line (Twitter API v2 fields:
12
+ # id, author_id, text, referenced_tweets, entities...)
13
+ posts: twitter/toy.json # required — path to the .json/.jsonl tweet file
14
+ # users: path/to/users.dat # optional — JSONL of user objects
15
+ # ({id, username, public_metrics}); used to map
16
+ # author ids -> usernames in outputs.
17
+ # Delete the line if you don't have it.
18
+
19
+ name: cop22 # dataset name: prefixes output files and cache keys.
20
+ # Changing it starts a fresh cache.
21
+
22
+ topic_model:
23
+ embedder: all-MiniLM-L6-v2 # any sentence-transformers model, or an OpenAI
24
+ # embedding model ("text-embedding-3-small" /
25
+ # "text-embedding-3-large"; requires OPENAI_API_KEY
26
+ # in .env)
27
+ min_topic_size: 50 # smallest cluster HDBSCAN will accept (= BERTopic's
28
+ # granularity knob). Too high -> everything is outlier
29
+ # -1; too low -> thousands of micro-topics.
30
+ # Rule of thumb: ~1/2000 of originals for large sets.
31
+ nr_topics: auto # "auto", or an int to force topic reduction after fit
32
+ random_state: 42 # seed; keep fixed for reproducibility
33
+ umap_n_neighbors: 15 # UMAP locality (kept for reference; pipeline uses
34
+ # adaptive values for small datasets)
35
+ umap_n_components: 5 # UMAP output dims (same note as above)
36
+ exact_probabilities: false # true = full membership matrix (much slower on big
37
+ # corpora); topics are identical either way
38
+ repro_mode: fast # "fast" = parallel UMAP+HDBSCAN (~20 min saved on
39
+ # 180k docs), results vary between runs;
40
+ # "strict" = seeded single-threaded UMAP,
41
+ # bit-reproducible, slower
42
+ umap_init: pca # default; deterministic when seeded and ~20x faster
43
+ # than spectral (see docs/UMAP_INIT_BENCHMARK.md).
44
+ # Pass "spectral" only if you specifically need it.
45
+ label_model: ollama/qwen2.5:7b # LLM that writes a short human-readable label per
46
+ # topic (any LiteLLM model string). Local & free
47
+ # via Ollama (https://ollama.com):
48
+ # ollama serve && ollama pull qwen2.5:7b
49
+ # Cloud examples: "gpt-4o-mini",
50
+ # "anthropic/claude-3-haiku-20240307".
51
+ # Custom OpenAI-compatible server (Unsloth,
52
+ # llama.cpp, vLLM...): prefix with "openai/" and
53
+ # set label_api_base, e.g.
54
+ # label_model: openai/unsloth/Qwen3.8-27B-GGUF
55
+ # label_api_base: http://localhost:8888/v1
56
+ # Requires the labeling extra:
57
+ # pip install 'topiclayers[labeling]'
58
+ # API key from LABEL_API_KEY / OPENAI_LIKE_API_KEY
59
+ # / OPENAI_API_KEY in .env. Set label_model to ""
60
+ # to disable; non-fatal if unreachable.
61
+ # label_context: "" # optional context sentence ("all documents are ...");
62
+ # by default derived from `name` for cop* datasets.
63
+ # Helps the LLM label the specific SUBTOPIC instead
64
+ # of the general domain. Topics are labeled in
65
+ # batches (~40 per call); use a strong model for
66
+ # publishable labels (e.g. gpt-4o-mini, <1 min and
67
+ # cents for a full dataset)
68
+ label_api_base: "" # base URL of a custom OpenAI-compatible endpoint;
69
+ # leave "" for Ollama/OpenAI. Can also be set via
70
+ # LABEL_API_BASE in .env
71
+
72
+ network:
73
+ type: multilayer_repost # one of:
74
+ # multilayer_repost -> retweet graph + multilayer
75
+ # network by topic (uunet)
76
+ # retweet -> retweet graph only (.gml)
77
+ # ttn -> topic-targeted network
78
+ # all -> all of the above
79
+ project_ttn: true # ttn only: also export the projected monopartite
80
+ # version
81
+
82
+ output_dir: ./out/cop22 # research outputs only: networks/, run_manifest.json,
83
+ # run_status.json, stage_timings.json
84
+
85
+ # cache_dir: ./cache # optional — where reusable artifacts live (processed
86
+ # data, embeddings, topic model). Defaults to a
87
+ # 'cache/' folder next to the input data file, so it
88
+ # persists regardless of output_dir and is shared
89
+ # between runs; safe to share across datasets (files
90
+ # are prefixed with `name`)
@@ -0,0 +1 @@
1
+ {"lang": "en", "possibly_sensitive": false, "public_metrics": {"retweet_count": 8, "reply_count": 0, "like_count": 0, "quote_count": 0, "impression_count": 0}, "conversation_id": "815345170468208642", "entities": {"mentions": [{"start": 3, "end": 14, "username": "pablorodas", "id": "52119056"}], "hashtags": [{"start": 16, "end": 30, "tag": "CLIMATEchange"}, {"start": 31, "end": 34, "tag": "p2"}, {"start": 112, "end": 118, "tag": "COP22"}, {"start": 119, "end": 124, "tag": "tcot"}, {"start": 125, "end": 128, "tag": "2A"}], "urls": [{"start": 88, "end": 111, "url": "https://t.co/f5lY9m4sE2", "expanded_url": "http://dlvr.it/N0sp69", "display_url": "dlvr.it/N0sp69"}], "annotations": [{"start": 113, "end": 117, "probability": 0.775, "type": "Other", "normalized_text": "COP22"}]}, "referenced_tweets": [{"type": "retweeted", "id": "815344316495237120"}], "text": "RT @pablorodas: #CLIMATEchange #p2 RT Our best environment stories from this past\u00a0year. https://t.co/f5lY9m4sE2 #COP22 #tcot #2A https://t.\u2026", "id": "815345170468208642", "created_at": "2016-12-31T23:53:31.000Z", "author_id": "17227307", "context_annotations": [{"domain": {"id": "25", "name": "Events", "description": ""}, "entity": {"id": "10040144401", "name": "Extreme Weather + Climate Change"}}, {"domain": {"id": "25", "name": "Events", "description": ""}, "entity": {"id": "10040529339", "name": "Discovery VCN - Earth"}}], "edit_history_tweet_ids": ["815345170468208642"]}
@@ -1,9 +1,19 @@
1
- """Run status: live stage tracking and ETA for long-running pipeline runs."""
1
+ """Run status: live stage tracking and ETA for long-running pipeline runs.
2
+
3
+ Writes `run_status.json` (machine-readable, consumed by `topiclayers status`)
4
+ and, when attached to a terminal, renders live console output: colored stage
5
+ banners, an in-place progress line with ETA, and a per-stage summary at the
6
+ end. A heartbeat keeps the elapsed time ticking (and the status file fresh)
7
+ during opaque stages that never call update(), so a silent stage is
8
+ distinguishable from a stuck one.
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
13
  import json
6
14
  import os
15
+ import shutil
16
+ import sys
7
17
  import threading
8
18
  import time
9
19
  from datetime import datetime, timezone
@@ -14,15 +24,32 @@ STAGES = ["load", "data", "embed", "topic_model", "label", "network", "manifest"
14
24
  _STATUS_FILE = "run_status.json"
15
25
  _TIMINGS_FILE = "stage_timings.json"
16
26
  _MIN_UPDATE_INTERVAL = 1.0
27
+ _MIN_RENDER_INTERVAL = 0.2
17
28
  _HEARTBEAT_INTERVAL = 5.0
29
+ _SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
30
+
31
+
32
+ def _supports_ansi() -> bool:
33
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
34
+
35
+
36
+ def _c(code: str, text: str) -> str:
37
+ if not _supports_ansi():
38
+ return text
39
+ return f"\033[{code}m{text}\033[0m"
40
+
41
+
42
+ def _fmt_count(n: int) -> str:
43
+ return f"{n:,}"
18
44
 
19
45
 
20
46
  class RunStatus:
21
- def __init__(self, output_dir: Path, name: str):
47
+ def __init__(self, output_dir: Path, name: str, console: bool = True):
22
48
  self.output_dir = Path(output_dir)
23
49
  self.name = name
50
+ self.console = console
24
51
  self.path = self.output_dir / _STATUS_FILE
25
- self.timings_path = self.output_dir / "cache" / _TIMINGS_FILE
52
+ self.timings_path = self.output_dir / _TIMINGS_FILE
26
53
  self.current_stage: Optional[str] = None
27
54
  self.stage_index: Optional[int] = None
28
55
  self.stage_total: Optional[int] = None
@@ -30,20 +57,111 @@ class RunStatus:
30
57
  self.started_at = datetime.now(timezone.utc)
31
58
  self.stage_started_at: Optional[datetime] = None
32
59
  self._last_write = 0.0
60
+ self._last_render = 0.0
61
+ self._line_active = False
62
+ self._spin_idx = 0
63
+ self._console_lock = threading.Lock()
64
+ self._stage_durations: dict[str, float] = {}
33
65
  self._stop_heartbeat = threading.Event()
34
66
  self._heartbeat_thread: Optional[threading.Thread] = None
35
67
 
68
+ # ------------------------------------------------------------------ console
69
+
70
+ def log(self, message: str) -> None:
71
+ """Print a message without clobbering the in-place progress line."""
72
+ with self._console_lock:
73
+ self._clear_line_locked()
74
+ print(message, flush=True)
75
+
76
+ def _clear_line_locked(self) -> None:
77
+ if self._line_active:
78
+ sys.stdout.write("\r\033[K")
79
+ sys.stdout.flush()
80
+ self._line_active = False
81
+
82
+ def _render(self, force: bool = False) -> None:
83
+ # in-place progress line; only meaningful on a real terminal
84
+ if not self.console or not sys.stdout.isatty():
85
+ return
86
+ now = time.monotonic()
87
+ if not force and now - self._last_render < _MIN_RENDER_INTERVAL:
88
+ return
89
+ self._last_render = now
90
+
91
+ with self._console_lock:
92
+ self._spin_idx += 1
93
+ spin = _c("2", _SPINNER[self._spin_idx % len(_SPINNER)])
94
+ stage = _c("36", self.current_stage) if self.current_stage else ""
95
+ bits = []
96
+ if (
97
+ self.processed is not None
98
+ and self.stage_total
99
+ and self.stage_total > 0
100
+ ):
101
+ pct = 100 * self.processed / self.stage_total
102
+ bits.append(
103
+ f"{_fmt_count(self.processed)}/{_fmt_count(self.stage_total)}"
104
+ f" ({pct:.0f}%)"
105
+ )
106
+ eta = self._eta_seconds()
107
+ if eta is not None:
108
+ bits.append(f"eta ~{_c('33', format_seconds(eta))}")
109
+ else:
110
+ bits.append(_c("2", "running…"))
111
+ if self.stage_started_at is not None:
112
+ elapsed = (datetime.now(timezone.utc) - self.stage_started_at).total_seconds()
113
+ bits.append(f"elapsed {format_seconds(elapsed)}")
114
+
115
+ line = f"{spin} {stage} " + _c("2", "·") + " " + f" {_c('2', '·')} ".join(bits)
116
+ width = shutil.get_terminal_size((80, 20)).columns
117
+ if len(line) + 8 > width:
118
+ line = line[: width - 8]
119
+ sys.stdout.write("\r\033[K" + line)
120
+ sys.stdout.flush()
121
+ self._line_active = True
122
+
123
+ def _banner(self, name: str, total: Optional[int], hist: Optional[float]) -> None:
124
+ with self._console_lock:
125
+ self._clear_line_locked()
126
+ if not self.console:
127
+ return
128
+ idx = STAGES.index(name) + 1 if name in STAGES else None
129
+ head = f"[{idx}/{len(STAGES)}] {name}" if idx else name
130
+ parts = [_c("2", "──"), _c("36;1", head)]
131
+ if total is not None:
132
+ parts.append(_c("2", f"── {_fmt_count(total)} items"))
133
+ if hist is not None:
134
+ parts.append(_c("2", f"── past runs ~{format_seconds(hist)}"))
135
+ print(" ".join(parts), flush=True)
136
+
137
+ def _summary(self, status: str) -> None:
138
+ with self._console_lock:
139
+ self._clear_line_locked()
140
+ if not self.console:
141
+ return
142
+ total = (datetime.now(timezone.utc) - self.started_at).total_seconds()
143
+ mark = _c("32", "✓") if status == "success" else _c("31", "✗")
144
+ print(f"\n{mark} run {status} in {format_seconds(total)}")
145
+ if self._stage_durations:
146
+ print(_c("2", " per-stage:"))
147
+ for stage, duration in self._stage_durations.items():
148
+ print(_c("2", f" {stage:<14} {format_seconds(duration):>10}"))
149
+
150
+ # ------------------------------------------------------------------- status
151
+
36
152
  def start(self) -> None:
37
153
  self._start_heartbeat()
38
154
  self._write(status="running")
39
155
 
40
156
  def _start_heartbeat(self) -> None:
41
157
  # keeps stage_elapsed_seconds fresh during opaque stages (e.g. UMAP +
42
- # HDBSCAN) that never call update()
158
+ # HDBSCAN) that never call update(); also re-renders the console line
159
+ # with an advancing spinner so silence is visibly not a hang
43
160
  def beat():
44
161
  while not self._stop_heartbeat.wait(_HEARTBEAT_INTERVAL):
45
162
  if self.current_stage is not None:
46
163
  self._write(status="running", force=True)
164
+ self._render(force=True)
47
165
 
48
166
  self._heartbeat_thread = threading.Thread(target=beat, daemon=True)
49
167
  self._heartbeat_thread.start()
@@ -59,15 +177,20 @@ class RunStatus:
59
177
  self.processed = 0
60
178
  self.stage_started_at = now
61
179
  self._write(status="running", force=True)
180
+ if self.console:
181
+ self._banner(name, total, historical_eta(self.timings_path, name))
182
+ self._last_render = time.monotonic()
62
183
 
63
184
  def update(self, processed: int, total: Optional[int] = None) -> None:
64
185
  self.processed = processed
65
186
  if total is not None:
66
187
  self.stage_total = total
67
188
  self._write(status="running", throttle=True)
189
+ self._render()
68
190
 
69
191
  def flush(self) -> None:
70
192
  self._write(status="running", force=True)
193
+ self._render(force=True)
71
194
 
72
195
  def finish(self, status: str = "success") -> None:
73
196
  self._stop_heartbeat.set()
@@ -75,6 +198,10 @@ class RunStatus:
75
198
  duration = (datetime.now(timezone.utc) - self.stage_started_at).total_seconds()
76
199
  self._record_timing(self.current_stage, duration)
77
200
  self._write(status=status, force=True)
201
+ if self.console:
202
+ self._summary(status)
203
+
204
+ # ------------------------------------------------------------------ helpers
78
205
 
79
206
  def _eta_seconds(self) -> Optional[float]:
80
207
  if (
@@ -120,6 +247,7 @@ class RunStatus:
120
247
  os.replace(tmp, self.path)
121
248
 
122
249
  def _record_timing(self, stage: str, duration: float) -> None:
250
+ self._stage_durations[stage] = duration
123
251
  self.timings_path.parent.mkdir(parents=True, exist_ok=True)
124
252
  records: list[dict] = []
125
253
  if self.timings_path.exists():
@@ -139,6 +267,15 @@ class RunStatus:
139
267
  os.replace(tmp, self.timings_path)
140
268
 
141
269
 
270
+ def log_via(reporter, message: str) -> None:
271
+ """Print a message through the reporter's console line (plain print fallback)."""
272
+ log_fn = getattr(reporter, "log", None)
273
+ if log_fn is not None:
274
+ log_fn(message)
275
+ else:
276
+ print(message)
277
+
278
+
142
279
  def load_status(path: Path) -> dict:
143
280
  with open(path) as f:
144
281
  return json.load(f)
@@ -202,7 +339,7 @@ def print_status(output_dir: Path) -> int:
202
339
  if eta is not None:
203
340
  print(f"Stage ETA: ~{format_seconds(eta)}")
204
341
  else:
205
- hist = historical_eta(output_dir / "cache" / _TIMINGS_FILE, stage)
342
+ hist = historical_eta(output_dir / _TIMINGS_FILE, stage)
206
343
  if hist is not None:
207
344
  print(f"No live ETA for this stage; past runs took ~{format_seconds(hist)} (median).")
208
345
  else:
File without changes