@noeva/analytics-core 0.1.0 → 0.3.0

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.
@@ -0,0 +1,213 @@
1
+ """noeva-snapshot ascolta: prende dalla coda di NOEVA i giri di snapshot del suo workspace e li esegue.
2
+
3
+ La cadenza la decide il workspace dal pannello NOEVA (Schedulazioni, azione `etl`,
4
+ funzione `analytics-snapshot-<progetto>`): lo scheduler crea un processo in coda, e
5
+ questo comando, sul server del cliente, lo prende (POST /api/processes/etl/claim,
6
+ solo le pipeline che conosce), genera lo snapshot dal database, chiede il biglietto
7
+ all'API (POST /api/analytics/biglietto-dati), lo pubblica nel contenitore e scrive
8
+ l'esito sul processo (PATCH /api/processes/etl/<id>/progress). Gli stessi endpoint
9
+ del worker noeva-etl; il server parla solo in uscita, verso NOEVA.
10
+
11
+ noeva-snapshot ascolta --config worker.json resta in ascolto (servizio)
12
+ noeva-snapshot ascolta --config worker.json --una-volta esegue cio' che c'e' in coda ed esce
13
+ (l'attivita' pianificata di Windows)
14
+
15
+ La chiave NOEVA del worker sta in NOEVA_API_KEY, mai nel file: e' la chiave di un
16
+ utente di servizio membro del workspace. worker.json (percorsi relativi al file):
17
+
18
+ {"api": "https://api.noeva.ai", "contenitore": "https://analytics.noeva.ai",
19
+ "workspace": "<id del workspace>", "lavoro": "lavoro",
20
+ "pipeline": {"analytics-snapshot-<id>": {"progetto": "<id>",
21
+ "pacchetto": "pacchetti/<id>", "connessione": "pacchetti/<id>/connessione.json"}}}
22
+
23
+ Solo libreria standard, come pubblica.py: il motore (esporta.py) si importa solo
24
+ quando c'e' un giro da fare.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import datetime as dt
29
+ import json
30
+ import os
31
+ import pathlib
32
+ import shutil
33
+ import socket
34
+ import sys
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ import pubblica
40
+
41
+ # Il report del giro (l'SQL eseguito, ADR 0004) va nell'esito del processo se ci
42
+ # sta; quello del progetto piu' grande pesa 460 KB. Oltre, solo il riassunto.
43
+ MAX_REPORT_BYTE = 512 * 1024
44
+ # I giri tenuti nella cartella di lavoro, per progetto: per rivedere un giro fallito.
45
+ GIRI_TENUTI = 5
46
+
47
+
48
+ def adesso() -> str:
49
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
50
+
51
+
52
+ def log(testo: str) -> None:
53
+ print(f"{dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {testo}", flush=True)
54
+
55
+
56
+ class ApiNoeva:
57
+ """Le due chiamate del worker all'API NOEVA, con la chiave e il workspace."""
58
+
59
+ def __init__(self, base: str, chiave: str, workspace: str, worker_id: str):
60
+ self.base, self.chiave, self.workspace, self.worker_id = base.rstrip("/"), chiave, workspace, worker_id
61
+
62
+ def _chiama(self, metodo: str, percorso: str, corpo: dict) -> tuple[int, dict | None]:
63
+ r = urllib.request.Request(
64
+ f"{self.base}{percorso}", data=json.dumps(corpo, default=str).encode(), method=metodo,
65
+ headers={"Authorization": f"Bearer {self.chiave}", "x-noeva-workspace": self.workspace,
66
+ "Content-Type": "application/json", "User-Agent": pubblica.AGENTE})
67
+ try:
68
+ with urllib.request.urlopen(r, timeout=60) as risposta:
69
+ testo = risposta.read()
70
+ return risposta.status, (json.loads(testo) if testo else None)
71
+ except urllib.error.HTTPError as e:
72
+ raise RuntimeError(f"{metodo} {percorso}: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") from e
73
+
74
+ def prendi(self, pipelines: list[str]) -> dict | None:
75
+ """Il prossimo processo in coda di una di queste pipeline, o None."""
76
+ stato, corpo = self._chiama("POST", "/api/processes/etl/claim",
77
+ {"workerId": self.worker_id, "pipelines": pipelines})
78
+ return None if stato == 204 or not corpo else corpo["payload"]
79
+
80
+ def avanza(self, processo: str, **campi) -> None:
81
+ self._chiama("PATCH", f"/api/processes/etl/{processo}/progress", campi)
82
+
83
+
84
+ def leggi_config(percorso: pathlib.Path) -> dict:
85
+ conf = json.loads(percorso.read_text(encoding="utf-8"))
86
+ radice = percorso.resolve().parent
87
+ for chiave in ("api", "contenitore", "workspace", "pipeline"):
88
+ if not conf.get(chiave):
89
+ raise SystemExit(f"{percorso}: manca {chiave}")
90
+ conf["lavoro"] = (radice / conf.get("lavoro", "lavoro")).resolve()
91
+ for nome, p in conf["pipeline"].items():
92
+ for chiave in ("progetto", "pacchetto", "connessione"):
93
+ if not p.get(chiave):
94
+ raise SystemExit(f"{percorso}: la pipeline {nome} non ha {chiave}")
95
+ p["pacchetto"] = (radice / p["pacchetto"]).resolve()
96
+ p["connessione"] = (radice / p["connessione"]).resolve()
97
+ return conf
98
+
99
+
100
+ def esporta_pacchetto(pacchetto: pathlib.Path, connessione: pathlib.Path, uscita: pathlib.Path,
101
+ report: pathlib.Path) -> None:
102
+ """Lo snapshot come `noeva-snapshot esporta`: solleva SystemExit se una cifra di
103
+ controllo non torna (allora non si pubblica niente)."""
104
+ import esporta # il motore: pesante, solo quando serve
105
+
106
+ codice = esporta.main(["esporta", str(pacchetto), "--mdl", str(pacchetto / "mdl.json"),
107
+ "--connessione", str(connessione), "--uscita", str(uscita), "--report", str(report)])
108
+ if codice:
109
+ raise SystemExit(f"esporta e' uscito con {codice}")
110
+
111
+
112
+ def riassunto(report: dict | None) -> dict:
113
+ if not report:
114
+ return {}
115
+ esiti: dict[str, int] = {}
116
+ for c in report.get("controlli", []):
117
+ esiti[c.get("esito", "?")] = esiti.get(c.get("esito", "?"), 0) + 1
118
+ return {
119
+ "controlli": esiti,
120
+ "avvisi": [c["nome"] for c in report.get("controlli", []) if c.get("esito") == "avviso"],
121
+ "estrazioni": len(report.get("estrazioni", [])),
122
+ }
123
+
124
+
125
+ def esegui(lavoro: dict, conf: dict, api: ApiNoeva, *, esporta_fn=esporta_pacchetto,
126
+ pubblica_fn=pubblica.pubblica_versione, biglietti_fn=pubblica.biglietto_dall_api) -> bool:
127
+ """Un giro: snapshot, biglietto, pubblicazione, esito sul processo. True se pubblicato."""
128
+ processo, pipeline = lavoro["processId"], lavoro["pipeline"]
129
+ p = conf["pipeline"][pipeline]
130
+ progetto = p["progetto"]
131
+ cartella = conf["lavoro"] / progetto / f"{dt.datetime.now().strftime('%Y%m%dT%H%M%S')}-{processo[:8]}"
132
+ dati, report_path = cartella / "dati", cartella / "giro.json"
133
+ dati.mkdir(parents=True, exist_ok=True)
134
+ log(f"{pipeline}: processo {processo}, giro in {cartella}")
135
+ inizio = time.time()
136
+ report = None
137
+ try:
138
+ api.avanza(processo, status="running", progress=5)
139
+ esporta_fn(p["pacchetto"], p["connessione"], dati, report_path)
140
+ report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else None
141
+ api.avanza(processo, status="running", progress=70)
142
+
143
+ da = f"{socket.gethostname()} noeva-snapshot"
144
+ esito = pubblica_fn(
145
+ progetto, dati, conf["contenitore"].rstrip("/"),
146
+ lambda versione: biglietti_fn(conf["api"], api.chiave, api.workspace, progetto, versione, da),
147
+ stampa=log)
148
+ uscita = {
149
+ "progetto": progetto, "versione": esito["versione"], "data_del_dato": esito.get("data_del_dato"),
150
+ "file": len(esito.get("file", [])), "byte": esito.get("byte"),
151
+ "secondi": round(time.time() - inizio, 1), **riassunto(report),
152
+ }
153
+ testo_report = json.dumps(report, default=str) if report else ""
154
+ if report and len(testo_report) <= MAX_REPORT_BYTE:
155
+ uscita["giro"] = report
156
+ api.avanza(processo, status="completed", progress=100, outputData=uscita, completedAt=adesso())
157
+ log(f"{pipeline}: pubblicata {esito['versione']} in {uscita['secondi']}s")
158
+ return True
159
+ except (Exception, SystemExit) as e: # noqa: BLE001 - qualunque errore chiude il processo come fallito
160
+ messaggio = str(e).strip() or e.__class__.__name__
161
+ log(f"{pipeline}: FALLITO, {messaggio}")
162
+ try:
163
+ api.avanza(processo, status="failed", errorMessage=messaggio[:2000],
164
+ outputData={"progetto": progetto, "secondi": round(time.time() - inizio, 1),
165
+ **riassunto(report)},
166
+ completedAt=adesso())
167
+ except Exception as e2: # noqa: BLE001 - l'API giu': il processo resta running, lo si vede da NOEVA
168
+ log(f"{pipeline}: non riesco a scrivere l'esito sul processo: {e2}")
169
+ return False
170
+ finally:
171
+ pulisci(conf["lavoro"] / progetto)
172
+
173
+
174
+ def pulisci(cartella_progetto: pathlib.Path) -> None:
175
+ giri = sorted((d for d in cartella_progetto.iterdir() if d.is_dir()), key=lambda d: d.name)
176
+ for vecchio in giri[:-GIRI_TENUTI]:
177
+ shutil.rmtree(vecchio, ignore_errors=True)
178
+
179
+
180
+ def main(argv: list[str]) -> int:
181
+ if "--config" not in argv or argv.index("--config") + 1 >= len(argv):
182
+ raise SystemExit("ascolta: serve --config <worker.json>")
183
+ conf = leggi_config(pathlib.Path(argv[argv.index("--config") + 1]))
184
+ una_volta = "--una-volta" in argv
185
+ intervallo = int(argv[argv.index("--intervallo") + 1]) if "--intervallo" in argv else 60
186
+ chiave = os.environ.get("NOEVA_API_KEY")
187
+ if not chiave:
188
+ raise SystemExit("manca NOEVA_API_KEY: la chiave NOEVA dell'utente di servizio del worker")
189
+ api = ApiNoeva(conf["api"], chiave, conf["workspace"], f"noeva-snapshot@{socket.gethostname()}")
190
+ pipelines = sorted(conf["pipeline"])
191
+ log(f"in ascolto per {', '.join(pipelines)} su {conf['api']}"
192
+ + (" (una volta)" if una_volta else f", ogni {intervallo}s"))
193
+
194
+ falliti = 0
195
+ while True:
196
+ try:
197
+ lavoro = api.prendi(pipelines)
198
+ except Exception as e: # noqa: BLE001 - rete o API giu': si riprova al prossimo giro
199
+ log(f"non riesco a chiedere lavoro all'API: {e}")
200
+ if una_volta:
201
+ return 1
202
+ time.sleep(intervallo)
203
+ continue
204
+ if lavoro:
205
+ falliti += not esegui(lavoro, conf, api)
206
+ continue # subito il prossimo: in coda ce ne puo' essere un altro
207
+ if una_volta:
208
+ return 1 if falliti else 0
209
+ time.sleep(intervallo)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ sys.exit(main(sys.argv))
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python
2
+ """L'eseguibile degli snapshot e il pacchetto di un progetto, per il worker dal cliente (ADR 0004).
3
+
4
+ Due cose separate, con ritmi diversi:
5
+
6
+ eseguibile noeva-snapshot[.exe]: motore Wren, driver, esporta.py e pubblica.py.
7
+ UNO per tutti i progetti, cambia solo con la versione del motore.
8
+ Stessa ricetta PyInstaller e stesso ambiente (lib/mcp-locale/ambiente)
9
+ dell'eseguibile MCP dei PC: le versioni provate sono quelle.
10
+ pacchetto <id>/: snapshot.yml e mdl.json con i nomi dei database del cliente
11
+ (la stessa trasformazione del pacchetto MCP: lib/mcp-locale/<id>.yml),
12
+ piu' connessione.json senza password. Pochi KB: a regime lo
13
+ scarica il worker dall'API a ogni giro.
14
+ worker accanto al pacchetto: worker.json (la pipeline del progetto, il
15
+ suo workspace, l'API e il contenitore) e installa.ps1 di
16
+ lib/snapshot/worker/: installa o aggiorna passo per passo (rete,
17
+ driver, chiavi, verifica, primo giro, attivita' pianificata che
18
+ lancia `noeva-snapshot ascolta --una-volta`) e chiude con l'esito.
19
+ Escono con `pacchetto`.
20
+
21
+ uv run --with pyyaml python lib/snapshot/impacchetta.py eseguibile
22
+ uv run --with pyyaml python lib/snapshot/impacchetta.py pacchetto project-<id>
23
+
24
+ oppure dal repository di un progetto (ADR 0005), con la configurazione in
25
+ consegna.yml e il workspace in analytics.json, uscita in dist/snapshot/ del
26
+ repository:
27
+
28
+ uv run --with pyyaml python $(npx analytics percorso)/lib/snapshot/impacchetta.py eseguibile .
29
+ uv run --with pyyaml python $(npx analytics percorso)/lib/snapshot/impacchetta.py pacchetto .
30
+
31
+ Escono in dist/snapshot/. Come per l'eseguibile MCP, quello per Windows si
32
+ costruisce su Windows; sul Mac pyodbc resta fuori (vuole unixODBC) e l'eseguibile
33
+ serve a provare la ricetta, non a leggere un SQL Server.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import pathlib
40
+ import shutil
41
+ import sys
42
+
43
+ import yaml
44
+
45
+ RADICE = pathlib.Path(__file__).resolve().parents[2]
46
+ QUI = pathlib.Path(__file__).resolve().parent
47
+ # lib/mcp-locale sta accanto a lib/snapshot nel monorepo e nel pacchetto (contenuto/lib/).
48
+ sys.path.insert(0, str(QUI.parent / "mcp-locale"))
49
+ import costruisci # noqa: E402 - la ricetta e l'ambiente dell'eseguibile MCP
50
+
51
+ USCITA = RADICE / "dist" / "snapshot"
52
+ NOME = "noeva-snapshot"
53
+ API = "https://api.noeva.ai"
54
+ CONTENITORE = "https://analytics.noeva.ai"
55
+ # Ogni quanti minuti l'attivita' pianificata chiede a NOEVA se c'e' un giro in coda.
56
+ MINUTI = 5
57
+
58
+
59
+ def argomenti(build: pathlib.Path) -> list:
60
+ return [
61
+ "--noconfirm", "--clean", "--onedir", "--console",
62
+ "--name", NOME,
63
+ "--distpath", build / "dist", "--workpath", build / "work", "--specpath", build,
64
+ # esporta.py e pubblica.py stanno accanto all'ingresso
65
+ "--paths", QUI,
66
+ "--collect-submodules", "wren", "--collect-data", "wren",
67
+ "--collect-submodules", "sqlglot",
68
+ *(["--exclude-module", "pyodbc"] if sys.platform == "darwin" else ["--hidden-import", "pyodbc"]),
69
+ "--copy-metadata", "wrenai",
70
+ # niente server MCP, memoria o interfacce: solo il motore e il runner
71
+ "--exclude-module", "lancedb", "--exclude-module", "onnxruntime",
72
+ "--exclude-module", "tkinter", "--exclude-module", "IPython",
73
+ "--exclude-module", "pkg_resources", "--exclude-module", "setuptools",
74
+ QUI / "noeva_snapshot.py",
75
+ ]
76
+
77
+
78
+ def eseguibile() -> pathlib.Path:
79
+ amb = costruisci.ambiente()
80
+ build = USCITA / "_pyinstaller"
81
+ if build.exists():
82
+ shutil.rmtree(build)
83
+ costruisci.uv_run(amb, "pyinstaller", *argomenti(build))
84
+ dest = USCITA / NOME
85
+ if dest.exists():
86
+ shutil.rmtree(dest)
87
+ shutil.copytree(build / "dist" / NOME, dest)
88
+ peso = sum(f.stat().st_size for f in dest.rglob("*") if f.is_file()) // 1024 // 1024
89
+ print(f"eseguibile: {costruisci.relativo(dest)} ({peso} MB)")
90
+ return dest
91
+
92
+
93
+ def pacchetto(progetto: str) -> pathlib.Path:
94
+ sorgente, ident, conf, _ = costruisci.progetto(progetto)
95
+ vive = conf.get("sorgenti") or {}
96
+ if len(vive) != 1:
97
+ raise SystemExit(f"{ident}: il pacchetto vuole una sorgente sola (un progetto, una sorgente), "
98
+ f"trovate {len(vive)}")
99
+ (chiave, s), = vive.items()
100
+
101
+ dest = USCITA / "pacchetti" / ident
102
+ if dest.exists():
103
+ shutil.rmtree(dest)
104
+ lavoro = USCITA / "_modelli" / ident
105
+ if lavoro.exists():
106
+ shutil.rmtree(lavoro)
107
+ lavoro.parent.mkdir(parents=True, exist_ok=True)
108
+ # Il modello con i nomi di produzione, come per il pacchetto MCP dei PC.
109
+ conta = costruisci.modello_vivo(sorgente, lavoro, s)
110
+ costruisci.uv_run(costruisci.ambiente(), "wren", "context", "build", "--path", lavoro)
111
+ dest.mkdir(parents=True)
112
+ shutil.copy2(lavoro / "target" / "mdl.json", dest / "mdl.json")
113
+ shutil.copy2(sorgente / "snapshot.yml", dest / "snapshot.yml")
114
+ # La sorgente senza password: la password la digita chi installa, nella
115
+ # variabile d'ambiente che password_env nomina.
116
+ connessione = {"tipo": "mssql", **{c: s[c] for c in ("host", "porta", "database", "utente", "password_env",
117
+ "driver", "opzioni") if c in s}}
118
+ (dest / "connessione.json").write_text(json.dumps(connessione, indent=1) + "\n", encoding="utf-8")
119
+ print(f"pacchetto: {costruisci.relativo(dest)} · {conta['rinominati']} riferimenti rinominati, "
120
+ f"{conta['catalogo']} modelli col catalogo {s.get('catalogo_senza_nome') or '-'} · sorgente {chiave}")
121
+ worker(ident, connessione, sorgente)
122
+ return dest
123
+
124
+
125
+ def workspace_di(ident: str, sorgente: pathlib.Path) -> str:
126
+ """Il workspace che possiede il progetto: analytics.json del repository, o il registro del monorepo."""
127
+ f = sorgente / "analytics.json"
128
+ if f.is_file():
129
+ workspace = json.loads(f.read_text(encoding="utf-8")).get("workspace")
130
+ else:
131
+ registro = json.loads((RADICE / "lib" / "rilascio" / "progetti.json").read_text(encoding="utf-8"))
132
+ workspace = ((registro["progetti"].get(ident) or {}).get("analytics") or {}).get("workspace")
133
+ if not workspace:
134
+ raise SystemExit(f"{ident}: manca il workspace (analytics.json del repository, o analytics.workspace nel registro)")
135
+ return workspace
136
+
137
+
138
+ def worker(ident: str, connessione: dict, sorgente: pathlib.Path) -> None:
139
+ """worker.json e gli script di Windows per `noeva-snapshot ascolta` (ADR 0004)."""
140
+ workspace = workspace_di(ident, sorgente)
141
+ conf = {
142
+ "api": API, "contenitore": CONTENITORE, "workspace": workspace, "lavoro": "lavoro",
143
+ "pipeline": {f"analytics-snapshot-{ident}": {
144
+ "progetto": ident, "pacchetto": f"pacchetti/{ident}",
145
+ "connessione": f"pacchetti/{ident}/connessione.json"}},
146
+ }
147
+ (USCITA / "worker.json").write_text(json.dumps(conf, indent=1) + "\n", encoding="utf-8")
148
+ valori = {"progetto": ident, "utente": connessione["utente"], "password_env": connessione["password_env"],
149
+ "host": connessione["host"], "porta": str(connessione.get("porta") or 1433), "minuti": str(MINUTI)}
150
+ for script in sorted((QUI / "worker").glob("*.ps1")):
151
+ testo = script.read_text(encoding="utf-8")
152
+ for k, v in valori.items():
153
+ testo = testo.replace("{{" + k + "}}", v)
154
+ if "{{" in testo:
155
+ raise SystemExit(f"{script.name}: segnaposto senza valore")
156
+ # PowerShell 5 legge come ANSI un .ps1 UTF-8 senza BOM: con il BOM gli accenti restano.
157
+ (USCITA / script.name).write_text(testo, encoding="utf-8-sig")
158
+ print(f"worker: worker.json (pipeline analytics-snapshot-{ident}, workspace {workspace}) e "
159
+ f"{len(list((QUI / 'worker').glob('*.ps1')))} script")
160
+
161
+
162
+ def main(argv: list[str]) -> int:
163
+ ap = argparse.ArgumentParser()
164
+ sub = ap.add_subparsers(dest="cosa", required=True)
165
+ e = sub.add_parser("eseguibile")
166
+ e.add_argument("progetto", nargs="?", help="il repository del progetto: l'uscita va nella sua dist/")
167
+ p = sub.add_parser("pacchetto")
168
+ p.add_argument("progetto", help="project-<id> del monorepo, o il repository del progetto (con analytics.json)")
169
+ a = ap.parse_args(argv[1:])
170
+ global USCITA
171
+ if a.progetto:
172
+ _, _, _, uscita_mcp = costruisci.progetto(a.progetto)
173
+ USCITA = uscita_mcp.parent / "snapshot"
174
+ costruisci.USCITA = uscita_mcp # relativo() stampa rispetto al repository
175
+ USCITA.mkdir(parents=True, exist_ok=True)
176
+ if a.cosa == "eseguibile":
177
+ eseguibile()
178
+ else:
179
+ pacchetto(a.progetto)
180
+ return 0
181
+
182
+
183
+ if __name__ == "__main__":
184
+ sys.exit(main(sys.argv))
@@ -0,0 +1,107 @@
1
+ """Il runner degli snapshot che gira dal cliente (ADR 0004), senza Docker e senza Python.
2
+
3
+ Si consegna come eseguibile (PyInstaller, `noeva-snapshot[.exe]`, costruito da
4
+ lib/snapshot/impacchetta.py). I giri li lancia lo scheduler di NOEVA: `ascolta`
5
+ li prende dalla coda del workspace (ascolta.py). E' UNO per tutti i
6
+ progetti: il modello non sta dentro, arriva con il pacchetto del progetto
7
+ (snapshot.yml + mdl.json) a ogni giro. Dentro ci sono il motore Wren, i driver e
8
+ gli stessi esporta.py e pubblica.py del repository: lo snapshot si genera come
9
+ da `./aggiorna.sh snapshot`.
10
+
11
+ noeva-snapshot esporta --pacchetto <dir> --connessione <file.json> --uscita <dir> --report <file.json>
12
+ [--senza-controlli]
13
+ noeva-snapshot pubblica <id> <dir dati> --url <contenitore>
14
+ noeva-snapshot ascolta --config <worker.json> [--una-volta] [--intervallo <s>]
15
+ noeva-snapshot verifica --pacchetto <dir> --connessione <file.json>
16
+ noeva-snapshot versione
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import pathlib
22
+ import sys
23
+
24
+ import ascolta
25
+ import esporta
26
+ import pubblica
27
+
28
+ VERSIONE = "0.2.1"
29
+
30
+
31
+ def _opzione(argv: list[str], nome: str) -> str | None:
32
+ if nome in argv:
33
+ i = argv.index(nome)
34
+ if i + 1 < len(argv):
35
+ return argv[i + 1]
36
+ return None
37
+
38
+
39
+ def esporta_(argv: list[str]) -> int:
40
+ pacchetto = _opzione(argv, "--pacchetto")
41
+ if not pacchetto:
42
+ raise SystemExit("esporta: serve --pacchetto <cartella con snapshot.yml e mdl.json>")
43
+ p = pathlib.Path(pacchetto).resolve()
44
+ resto = [a for i, a in enumerate(argv) if a != "--pacchetto" and (i == 0 or argv[i - 1] != "--pacchetto")]
45
+ return esporta.main(["esporta", str(p), "--mdl", str(p / "mdl.json"), *resto])
46
+
47
+
48
+ def verifica(argv: list[str]) -> int:
49
+ """Connessione e cifre di controllo, senza scrivere niente: quello che si
50
+ lancia a mano da chi installa, prima di lasciare il server."""
51
+ pacchetto = pathlib.Path(_opzione(argv, "--pacchetto") or ".").resolve()
52
+ connessione = _opzione(argv, "--connessione")
53
+ if not connessione:
54
+ raise SystemExit("verifica: serve --connessione <file.json>")
55
+ import yaml
56
+
57
+ conf = yaml.safe_load((pacchetto / "snapshot.yml").read_text(encoding="utf-8")) or {}
58
+ eng, nome, _ = esporta.motore(pacchetto, mdl=pacchetto / "mdl.json", connessione=pathlib.Path(connessione))
59
+ print(f"noeva-snapshot {VERSIONE} · {pacchetto.name} · {nome}")
60
+ falliti = 0
61
+ try:
62
+ for c in conf.get("controlli") or []:
63
+ try:
64
+ r = eng.query(c["sql"]).to_pylist()
65
+ trovato = float(next(iter(r[0].values())) or 0) if r else 0.0
66
+ esito = esporta.valuta(c, trovato)
67
+ print(f" {'NO' if esito == 'diverso' else esito} {c['nome']}: {trovato:,.2f} "
68
+ f"(atteso {float(c['atteso']):,.2f})")
69
+ falliti += esito == "diverso"
70
+ except Exception as e: # noqa: BLE001 - si mostra l'errore, qualunque sia
71
+ # Il primo errore basta: quasi sempre e' la connessione (rete, driver,
72
+ # password), e ripeterlo per ogni controllo vorrebbe dire un timeout ciascuno.
73
+ print(f" NO {c['nome']}: {str(e).strip().splitlines()[0][:300]}")
74
+ falliti += 1
75
+ print(" mi fermo al primo errore: sistemato quello, si rilancia")
76
+ break
77
+ finally:
78
+ eng.close()
79
+ print("tutto a posto" if not falliti else f"{falliti} controlli non tornano (gli avvisi non fermano)")
80
+ return 1 if falliti else 0
81
+
82
+
83
+ def main(argv: list[str]) -> int:
84
+ # Su Windows, in una pipe o in un file, la console non e' UTF-8: chi installa
85
+ # legge «·» e gli accenti come «�». UTF-8 sempre, e mai un'eccezione per un carattere.
86
+ for flusso in (sys.stdout, sys.stderr):
87
+ flusso.reconfigure(encoding="utf-8", errors="replace")
88
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
89
+ print(__doc__)
90
+ return 0
91
+ comando, resto = argv[1], argv[2:]
92
+ if comando == "esporta":
93
+ return esporta_(resto)
94
+ if comando == "pubblica":
95
+ return pubblica.main(["pubblica", *resto])
96
+ if comando == "verifica":
97
+ return verifica(resto)
98
+ if comando == "ascolta":
99
+ return ascolta.main(["ascolta", *resto])
100
+ if comando == "versione":
101
+ print(json.dumps({"runner": VERSIONE, "python": sys.version.split()[0]}))
102
+ return 0
103
+ raise SystemExit(f"comando sconosciuto: {comando} (esporta, pubblica, ascolta, verifica, versione)")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main(sys.argv))
@@ -46,22 +46,37 @@ def _b64(byte: bytes) -> str:
46
46
  return base64.urlsafe_b64encode(byte).decode().rstrip("=")
47
47
 
48
48
 
49
- def biglietto(segreto: str, progetto: str, versione: str, ws: str, da: str, durata_s: int = 900) -> str:
49
+ def biglietto(segreto: str, progetto: str, versione: str, ws: str, da: str, durata_s: int = 900,
50
+ tipo: str = "dati") -> str:
50
51
  """Lo stesso formato di lib/cancello/sessione.js: base64url(JSON).base64url(HMAC-SHA256).
51
52
 
52
53
  `ws` e' il workspace che possiede il progetto: il contenitore rifiuta un biglietto
53
- con un altro workspace (lo stesso controllo vale per quelli dell'API NOEVA)."""
54
- payload = {"tipo": "dati", "prog": progetto, "ver": versione, "ws": ws, "da": da,
54
+ con un altro workspace (lo stesso controllo vale per quelli dell'API NOEVA).
55
+ `tipo` dice cosa apre: "dati" lo snapshot, "modello" il modello compilato
56
+ (lib/modello/pubblica.py)."""
57
+ payload = {"tipo": tipo, "prog": progetto, "ver": versione, "ws": ws, "da": da,
55
58
  "exp": int((time.time() + durata_s) * 1000)}
56
59
  corpo = _b64(json.dumps(payload, separators=(",", ":")).encode())
57
60
  mac = hmac.new(segreto.encode(), corpo.encode(), hashlib.sha256).digest()
58
61
  return f"{corpo}.{_b64(mac)}"
59
62
 
60
63
 
61
- def workspace_dal_registro(progetto: str) -> str:
64
+ def workspace_dal_registro(progetto: str, vicino: pathlib.Path | None = None) -> str:
65
+ """Il workspace che possiede il progetto: da analytics.json del repository del
66
+ progetto (risalendo da `vicino`, la cartella dei dati), o dal registro del monorepo.
67
+
68
+ Fuori dal monorepo il registro non c'e' (ADR 0005): il repository del progetto
69
+ scrive il suo workspace in analytics.json."""
70
+ for d in ([vicino.resolve(), *vicino.resolve().parents] if vicino else []):
71
+ f = d / "analytics.json"
72
+ if f.is_file():
73
+ c = json.loads(f.read_text(encoding="utf-8"))
74
+ if c.get("progetto") == progetto and c.get("workspace"):
75
+ return c["workspace"]
76
+ break
62
77
  registro = pathlib.Path(__file__).resolve().parents[1] / "rilascio" / "progetti.json"
63
78
  if not registro.is_file():
64
- raise SystemExit("serve --workspace: il registro dei progetti non c'e'")
79
+ raise SystemExit("serve --workspace (o `workspace` in analytics.json): il registro dei progetti non c'e'")
65
80
  voce = json.loads(registro.read_text(encoding="utf-8"))["progetti"].get(progetto) or {}
66
81
  ws = (voce.get("analytics") or {}).get("workspace")
67
82
  if not ws:
@@ -75,11 +90,13 @@ API_NOEVA = "https://api.noeva.ai"
75
90
  AGENTE = "noeva-snapshot (NOEVA Analytics)"
76
91
 
77
92
 
78
- def biglietto_dall_api(api: str, chiave: str, workspace: str, progetto: str, versione: str, da: str) -> str:
93
+ def biglietto_dall_api(api: str, chiave: str, workspace: str, progetto: str, versione: str, da: str,
94
+ tipo: str = "dati") -> str:
79
95
  """Il biglietto rilasciato dall'API NOEVA a un membro del workspace (noeva-server-api,
80
96
  POST /api/analytics/biglietto-dati). Il contenitore lo accetta solo se il workspace
81
- possiede il progetto."""
82
- corpo = json.dumps({"progetto": progetto, "versione": versione, "da": da}).encode()
97
+ possiede il progetto. `tipo` "dati" non si manda: l'API di prima non lo conosce."""
98
+ richiesta = {"progetto": progetto, "versione": versione, "da": da, **({"tipo": tipo} if tipo != "dati" else {})}
99
+ corpo = json.dumps(richiesta).encode()
83
100
  risposta = chiama("POST", f"{api.rstrip('/')}/api/analytics/biglietto-dati", chiave, corpo,
84
101
  intestazioni={"x-noeva-workspace": workspace, "Content-Type": "application/json"})
85
102
  return risposta["payload"]["biglietto"]
@@ -96,7 +113,8 @@ def dal_file(variabile: str, nome: str) -> str | None:
96
113
  return f.read_text(encoding="utf-8").strip() or None if f.is_file() else None
97
114
 
98
115
 
99
- def sorgente_biglietti(progetto: str, da: str, *, api: str | None = None, workspace: str | None = None):
116
+ def sorgente_biglietti(progetto: str, da: str, *, api: str | None = None, workspace: str | None = None,
117
+ tipo: str = "dati", vicino: pathlib.Path | None = None):
100
118
  """Una funzione versione -> biglietto. Due strade:
101
119
 
102
120
  - firmato qui col segreto del contenitore (SEGRETO_DATI o ~/.segreto-dati): il Mac
@@ -105,18 +123,19 @@ def sorgente_biglietti(progetto: str, da: str, *, api: str | None = None, worksp
105
123
  sia membro del workspace che possiede il progetto, col modulo Analytics. E' la
106
124
  strada di chi rilascia i dati senza avere il segreto, e del worker dal cliente.
107
125
 
108
- Il workspace e' --workspace, NOEVA_WORKSPACE o quello del registro."""
126
+ Il workspace e' --workspace, NOEVA_WORKSPACE, quello di analytics.json del repository
127
+ del progetto o quello del registro. `tipo` dice cosa apre il biglietto: dati, pagine, modello."""
109
128
  segreto = dal_file("SEGRETO_DATI", ".segreto-dati")
110
129
  if segreto:
111
- ws = workspace or workspace_dal_registro(progetto)
112
- return lambda versione: biglietto(segreto, progetto, versione, ws, da)
130
+ ws = workspace or workspace_dal_registro(progetto, vicino)
131
+ return lambda versione: biglietto(segreto, progetto, versione, ws, da, tipo=tipo)
113
132
  chiave = dal_file("NOEVA_API_KEY", ".noeva-api-key")
114
133
  if not chiave:
115
134
  raise SystemExit("serve il segreto del contenitore (~/.segreto-dati) o la tua chiave API NOEVA "
116
135
  "(~/.noeva-api-key): vedi docs/pubblicare-le-dashboard.md")
117
- ws = workspace or os.environ.get("NOEVA_WORKSPACE") or workspace_dal_registro(progetto)
136
+ ws = workspace or os.environ.get("NOEVA_WORKSPACE") or workspace_dal_registro(progetto, vicino)
118
137
  base_api = api or os.environ.get("NOEVA_API", API_NOEVA)
119
- return lambda versione: biglietto_dall_api(base_api, chiave, ws, progetto, versione, da)
138
+ return lambda versione: biglietto_dall_api(base_api, chiave, ws, progetto, versione, da, tipo=tipo)
120
139
 
121
140
 
122
141
  def versione_di(cartella: pathlib.Path) -> str:
@@ -192,7 +211,8 @@ def main(argv: list[str]) -> int:
192
211
  ap.add_argument("--api", help=f"l'API NOEVA che rilascia il biglietto (default: NOEVA_API o {API_NOEVA})")
193
212
  a = ap.parse_args(argv[1:])
194
213
 
195
- biglietto_per = sorgente_biglietti(a.progetto, a.da, api=a.api, workspace=a.workspace)
214
+ biglietto_per = sorgente_biglietti(a.progetto, a.da, api=a.api, workspace=a.workspace,
215
+ vicino=pathlib.Path(a.dati) if a.dati else None)
196
216
  base = a.url.rstrip("/")
197
217
 
198
218
  if a.rollback: