topiclayers 0.2.1__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.
- {topiclayers-0.2.1 → topiclayers-0.3.0}/PKG-INFO +21 -11
- {topiclayers-0.2.1 → topiclayers-0.3.0}/README.md +20 -10
- {topiclayers-0.2.1 → topiclayers-0.3.0}/pyproject.toml +1 -1
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/cli.py +17 -15
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/config.py +17 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/data.py +8 -7
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/network.py +4 -2
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/topic.py +31 -21
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/template_csv.yml +6 -1
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/template_twitter.yml +9 -3
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/status.py +142 -5
- {topiclayers-0.2.1 → topiclayers-0.3.0}/LICENSE +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/__init__.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/__init__.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/csv.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/registry.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/twitter.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/__init__.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/generic/posts.csv +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/generic.yml +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/twitter/toy.json +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/manifest.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/schema.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/support/__init__.py +0 -0
- {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/support/cache.py +0 -0
- {topiclayers-0.2.1 → 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.
|
|
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
|
|
@@ -103,7 +103,7 @@ Stage ETA: ~35m 20s
|
|
|
103
103
|
Total elapsed: 21m 05s
|
|
104
104
|
```
|
|
105
105
|
|
|
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>/
|
|
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.
|
|
107
107
|
|
|
108
108
|
## Topic labeling with an LLM (optional)
|
|
109
109
|
|
|
@@ -180,12 +180,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
180
180
|
<output_dir>/
|
|
181
181
|
├── run_manifest.json # Reproducibility metadata
|
|
182
182
|
├── run_status.json # Live stage/ETA tracking (while running)
|
|
183
|
-
├──
|
|
184
|
-
│ └── data/
|
|
185
|
-
│ ├── tweets_<name>.pkl/.csv # Full tweet table
|
|
186
|
-
│ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
|
|
187
|
-
│ └── manifest_<name>.json # Cache validity key
|
|
188
|
-
│ └── stage_timings.json # Per-stage durations for ETA prediction
|
|
183
|
+
├── stage_timings.json # Per-stage durations for ETA prediction
|
|
189
184
|
└── networks/
|
|
190
185
|
├── <name>_retweet.gml # Single-layer retweet network
|
|
191
186
|
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
@@ -194,13 +189,28 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
194
189
|
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
195
190
|
```
|
|
196
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
|
+
|
|
197
210
|
## Requirements
|
|
198
211
|
|
|
199
212
|
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
200
213
|
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
201
214
|
|
|
202
|
-
## Planned: JOSS software paper
|
|
203
215
|
|
|
204
|
-
Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
|
|
205
|
-
a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
|
|
206
216
|
|
|
@@ -67,7 +67,7 @@ Stage ETA: ~35m 20s
|
|
|
67
67
|
Total elapsed: 21m 05s
|
|
68
68
|
```
|
|
69
69
|
|
|
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>/
|
|
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.
|
|
71
71
|
|
|
72
72
|
## Topic labeling with an LLM (optional)
|
|
73
73
|
|
|
@@ -144,12 +144,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
144
144
|
<output_dir>/
|
|
145
145
|
├── run_manifest.json # Reproducibility metadata
|
|
146
146
|
├── run_status.json # Live stage/ETA tracking (while running)
|
|
147
|
-
├──
|
|
148
|
-
│ └── data/
|
|
149
|
-
│ ├── tweets_<name>.pkl/.csv # Full tweet table
|
|
150
|
-
│ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
|
|
151
|
-
│ └── manifest_<name>.json # Cache validity key
|
|
152
|
-
│ └── stage_timings.json # Per-stage durations for ETA prediction
|
|
147
|
+
├── stage_timings.json # Per-stage durations for ETA prediction
|
|
153
148
|
└── networks/
|
|
154
149
|
├── <name>_retweet.gml # Single-layer retweet network
|
|
155
150
|
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
@@ -158,12 +153,27 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
158
153
|
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
159
154
|
```
|
|
160
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
|
+
|
|
161
174
|
## Requirements
|
|
162
175
|
|
|
163
176
|
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
164
177
|
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
165
178
|
|
|
166
|
-
## Planned: JOSS software paper
|
|
167
179
|
|
|
168
|
-
Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
|
|
169
|
-
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.
|
|
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"
|
|
@@ -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
|
-
|
|
48
|
+
status.log(f"Loading posts via '{fmt}' adapter from {input_path}")
|
|
49
49
|
posts = loader(input_path)
|
|
50
|
-
|
|
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.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
status.log(f" Skipping multilayer network: {e}")
|
|
134
137
|
nw.create_ttnetwork(project=config.network.project_ttn)
|
|
135
138
|
else:
|
|
136
|
-
|
|
137
|
-
|
|
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,7 +170,6 @@ 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
|
|
|
170
|
-
|
|
171
173
|
def _cmd_init(dest: Path) -> None:
|
|
172
174
|
import shutil
|
|
173
175
|
from importlib.resources import as_file, files
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
log_via(reporter, f"Unresolved references: {unresolved_count}")
|
|
201
202
|
if unresolved_count > 0:
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
+
log_via(reporter, f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
|
|
613
623
|
return labels
|
|
614
624
|
|
|
615
625
|
|
|
@@ -41,4 +41,9 @@ network:
|
|
|
41
41
|
# interaction_type=repost rows to build edges
|
|
42
42
|
project_ttn: true
|
|
43
43
|
|
|
44
|
-
output_dir: ./out/my_dataset
|
|
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.
|
|
@@ -79,6 +79,12 @@ network:
|
|
|
79
79
|
project_ttn: true # ttn only: also export the projected monopartite
|
|
80
80
|
# version
|
|
81
81
|
|
|
82
|
-
output_dir: ./out/cop22 #
|
|
83
|
-
#
|
|
84
|
-
|
|
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`)
|
|
@@ -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 /
|
|
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 /
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|