@noeva/analytics-core 0.1.0 → 0.3.2

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,167 @@
1
+ """Da dove il servizio locale prende il modello (ADR 0005, punto 6).
2
+
3
+ Il modello e' del cliente e sta nel repository del progetto; l'eseguibile e' di
4
+ 4D e cambia di rado. Per questo il modello non e' piu' cucito nell'eseguibile:
5
+ all'avvio si cerca, in quest'ordine,
6
+
7
+ 1. nel repository, se Claude Code e' aperto li' (la cartella corrente o una sopra
8
+ ha analytics.json con questo progetto): si compila il modello del disco,
9
+ modifiche non committate comprese, cosi' chi sviluppa prova il modello che sta
10
+ scrivendo, prima del push;
11
+ 2. nell'ultima versione pubblicata sul contenitore (/_modello/<id>/corrente.json),
12
+ scaricata con la chiave API NOEVA dell'utente e tenuta in cache: una modifica
13
+ al modello arriva ai PC senza un rilascio di 4D;
14
+ 3. senza rete o senza contenitore, nell'ultima versione in cache;
15
+ 4. per ultimo nel modello della cartella consegnata, finche' le cartelle lo
16
+ portano ancora.
17
+
18
+ La configurazione sta nella sorgente di noeva.json:
19
+
20
+ "modello_r2": {"progetto": "<id>", "url": "https://analytics.noeva.ai", "chiave_env": "NOEVA_API_KEY"}
21
+
22
+ Senza `modello_r2` vale solo il punto 4, come prima. Niente qui stampa su stdout:
23
+ e' il canale MCP.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import pathlib
30
+ import shutil
31
+ import sys
32
+ import tempfile
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+
37
+ # Cloudflare, davanti al contenitore, blocca lo user agent di urllib.
38
+ AGENTE = "noeva-mcp-locale (NOEVA Analytics)"
39
+ ATTESA_S = 15
40
+
41
+
42
+ def log(msg: str) -> None:
43
+ print(f"noeva: {msg}", file=sys.stderr, flush=True)
44
+
45
+
46
+ def cartella_cache(progetto: str) -> pathlib.Path:
47
+ """%LOCALAPPDATA%\\NOEVA\\modelli\\<id> su Windows, ~/.cache/noeva/modelli/<id> altrove."""
48
+ if os.environ.get("NOEVA_CACHE"):
49
+ base = pathlib.Path(os.environ["NOEVA_CACHE"])
50
+ elif os.environ.get("LOCALAPPDATA"):
51
+ base = pathlib.Path(os.environ["LOCALAPPDATA"]) / "NOEVA"
52
+ else:
53
+ base = pathlib.Path.home() / ".cache" / "noeva"
54
+ return base / "modelli" / progetto
55
+
56
+
57
+ def repository_aperto(progetto: str, da: pathlib.Path | None = None) -> pathlib.Path | None:
58
+ """La radice del repository del progetto, se Claude Code e' aperto li' o sotto.
59
+
60
+ NOEVA_REPOSITORY la forza (per provare da un'altra cartella)."""
61
+ if os.environ.get("NOEVA_REPOSITORY"):
62
+ candidati = [pathlib.Path(os.environ["NOEVA_REPOSITORY"]).resolve()]
63
+ else:
64
+ d = (da or pathlib.Path.cwd()).resolve()
65
+ candidati = [d, *d.parents]
66
+ for c in candidati:
67
+ f = c / "analytics.json"
68
+ if not f.is_file():
69
+ continue
70
+ try:
71
+ if json.loads(f.read_text(encoding="utf-8")).get("progetto") == progetto and (c / "wren_project.yml").is_file():
72
+ return c
73
+ except (OSError, ValueError):
74
+ pass
75
+ return None # il primo analytics.json trovato decide: e' un altro progetto
76
+ return None
77
+
78
+
79
+ def compila_dal_repository(radice: pathlib.Path, cache: pathlib.Path) -> pathlib.Path:
80
+ from consegna import compila, leggi_consegna, modello_di_consegna
81
+
82
+ dest = cache / "repository"
83
+ if dest.exists():
84
+ shutil.rmtree(dest)
85
+ modello_di_consegna(radice, dest, leggi_consegna(radice))
86
+ compila(dest)
87
+ return dest
88
+
89
+
90
+ def chiedi(url: str, chiave: str) -> bytes:
91
+ r = urllib.request.Request(url, headers={"Authorization": f"Bearer {chiave}", "User-Agent": AGENTE})
92
+ with urllib.request.urlopen(r, timeout=ATTESA_S) as risposta:
93
+ return risposta.read()
94
+
95
+
96
+ def scarica(conf: dict, chiave: str, cache: pathlib.Path) -> tuple[pathlib.Path, str]:
97
+ """L'ultima versione pubblicata, dalla cache se c'e' gia' tutta. Solleva se il
98
+ contenitore non risponde o rifiuta: chi chiama ricade sulla cache."""
99
+ base = f"{conf['url'].rstrip('/')}/_modello/{conf['progetto']}"
100
+ corrente = json.loads(chiedi(f"{base}/corrente.json", chiave))
101
+ versione = corrente["versione"]
102
+ dest = cache / "versioni" / versione
103
+ if (dest / ".completa").is_file():
104
+ return dest, versione
105
+ # Si scarica accanto e si sposta alla fine: una versione a meta' (rete caduta)
106
+ # non diventa mai quella da usare.
107
+ (cache / "versioni").mkdir(parents=True, exist_ok=True)
108
+ tmp = pathlib.Path(tempfile.mkdtemp(prefix=f".{versione}-", dir=cache / "versioni"))
109
+ try:
110
+ for rel in corrente["file"]:
111
+ p = tmp / rel
112
+ if ".." in pathlib.PurePosixPath(rel).parts:
113
+ raise ValueError(f"percorso non ammesso nel modello pubblicato: {rel}")
114
+ p.parent.mkdir(parents=True, exist_ok=True)
115
+ p.write_bytes(chiedi(f"{base}/{versione}/{urllib.parse.quote(rel)}", chiave))
116
+ (tmp / ".completa").write_text(json.dumps({"versione": versione, "pubblicata": corrente.get("pubblicata")}),
117
+ encoding="utf-8")
118
+ if dest.exists():
119
+ shutil.rmtree(dest)
120
+ tmp.rename(dest)
121
+ finally:
122
+ if tmp.exists():
123
+ shutil.rmtree(tmp, ignore_errors=True)
124
+ return dest, versione
125
+
126
+
127
+ def ultima_in_cache(cache: pathlib.Path) -> tuple[pathlib.Path, str] | None:
128
+ complete = [p.parent for p in (cache / "versioni").glob("*/.completa")]
129
+ if not complete:
130
+ return None
131
+ p = max(complete, key=lambda d: (d / ".completa").stat().st_mtime)
132
+ return p, p.name
133
+
134
+
135
+ def trova(s: dict, consegnata: pathlib.Path) -> tuple[pathlib.Path, str]:
136
+ """La cartella del modello (con target/mdl.json) e da dove viene, in parole."""
137
+ conf = s.get("modello_r2")
138
+ if not conf:
139
+ return consegnata, "il modello della cartella consegnata"
140
+ progetto = conf["progetto"]
141
+ cache = cartella_cache(progetto)
142
+
143
+ repo = repository_aperto(progetto)
144
+ if repo:
145
+ try:
146
+ return compila_dal_repository(repo, cache), f"il repository aperto ({repo}), compilato all'avvio"
147
+ except Exception as e: # noqa: BLE001 - un modello rotto nel repository non deve spegnere il servizio
148
+ log(f"il modello del repository {repo} non si compila ({e}): uso quello pubblicato")
149
+
150
+ chiave = os.environ.get(conf.get("chiave_env") or "NOEVA_API_KEY") or ""
151
+ if chiave.startswith("${"):
152
+ chiave = "" # .mcp.json con "${VAR}" e la variabile assente
153
+ if chiave:
154
+ try:
155
+ dest, versione = scarica(conf, chiave, cache)
156
+ return dest, f"la versione pubblicata {versione} ({conf['url']})"
157
+ except urllib.error.HTTPError as e:
158
+ log(f"il contenitore rifiuta il modello di {progetto}: HTTP {e.code} {e.read()[:200].decode(errors='replace')}")
159
+ except Exception as e: # noqa: BLE001 - rete, DNS, timeout: si ricade sulla cache
160
+ log(f"il contenitore non risponde ({e}): uso il modello in cache")
161
+ else:
162
+ log(f"manca la chiave API NOEVA nella variabile {conf.get('chiave_env') or 'NOEVA_API_KEY'}: niente aggiornamenti del modello")
163
+
164
+ in_cache = ultima_in_cache(cache)
165
+ if in_cache:
166
+ return in_cache[0], f"la versione {in_cache[1]} in cache (non aggiornata a questo avvio)"
167
+ return consegnata, "il modello della cartella consegnata (nessuna versione pubblicata raggiungibile)"
@@ -0,0 +1,203 @@
1
+ """Il server MCP di un progetto NOEVA Analytics sul PC del cliente, senza Docker e senza Python.
2
+
3
+ Si consegna come eseguibile (PyInstaller, `noeva-<progetto>.exe`) dentro la
4
+ cartella di lavoro che il cliente apre con Claude Code: `.mcp.json` lo avvia da
5
+ solo, una volta per sorgente (docs/spike-mcpb-windows.md). Gira anche da
6
+ sorgente con Python, ed e' quello che fa l'estensione Claude Desktop (.mcpb).
7
+
8
+ E' lo stesso server di `wren serve mcp` (gli stessi strumenti, lo stesso motore
9
+ in processo), costruito qui invece che dalla CLI perche' sul PC del cliente non
10
+ ci sono ~/.wren/profiles.yml ne' il repository: la configurazione sta in
11
+ noeva.json accanto all'eseguibile, le password in variabili d'ambiente
12
+ dell'utente Windows, e il server si presenta col nome NOEVA (ADR 0001).
13
+
14
+ Una sorgente e' un database del cliente con il suo pezzo di modello (per esempio
15
+ erp e dwh, ognuna col suo server, database, utente), oppure lo snapshot delle
16
+ dashboard su DuckDB come ripiego offline.
17
+
18
+ noeva-<id> --sorgente erp serve MCP su stdio (lo lancia Claude Code)
19
+ noeva-<id> --verifica controlla driver, password e connessione di
20
+ tutte le sorgenti e dice cosa manca: si lancia
21
+ a mano dal PowerShell quando /mcp dice "failed"
22
+
23
+ Su stdio lo stdout e' il canale del protocollo: fuori da --verifica qui non si
24
+ stampa niente, i log vanno su stderr.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import base64
30
+ import json
31
+ import os
32
+ import pathlib
33
+ import sys
34
+
35
+ # Congelato da PyInstaller la cartella e' quella dell'eseguibile; da sorgente,
36
+ # quella sopra server/. NOEVA_DIR la forza (per provare da un'altra macchina).
37
+ if os.environ.get("NOEVA_DIR"):
38
+ QUI = pathlib.Path(os.environ["NOEVA_DIR"]).resolve()
39
+ elif getattr(sys, "frozen", False):
40
+ QUI = pathlib.Path(sys.executable).resolve().parent
41
+ else:
42
+ QUI = pathlib.Path(__file__).resolve().parent.parent
43
+
44
+
45
+ # Da sorgente, i moduli accanto (modello_locale) e lib/modello (consegna): congelato,
46
+ # PyInstaller li ha gia' dentro (costruisci.py, --paths).
47
+ if not getattr(sys, "frozen", False):
48
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
49
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "modello"))
50
+
51
+
52
+ class Problema(Exception):
53
+ """Un errore di configurazione da spiegare a chi installa, non uno stack."""
54
+
55
+
56
+ def configurazione() -> dict:
57
+ p = QUI / "noeva.json"
58
+ if not p.is_file():
59
+ raise Problema(f"manca {p}: la cartella di NOEVA e' incompleta")
60
+ return json.loads(p.read_text(encoding="utf-8"))
61
+
62
+
63
+ def connessione_mssql(s: dict) -> dict:
64
+ env = os.environ
65
+ utente = s.get("utente") or ""
66
+ var = s.get("password_env") or ""
67
+ password = env.get(var) if var else None
68
+ if password and password.startswith("${"):
69
+ # .mcp.json con "${VAR}" e la variabile assente: Claude Code passa il
70
+ # testo cosi' com'e'. Meglio dirlo che tentare il login con quello.
71
+ password = None
72
+ if utente and not password:
73
+ raise Problema(
74
+ f"manca la password di {utente}: va nella variabile d'ambiente {var} dell'utente "
75
+ f"Windows (imposta-password.ps1), poi si chiude e si riapre Claude Code"
76
+ )
77
+ try:
78
+ import pyodbc
79
+ except ImportError as e:
80
+ # Su Windows pyodbc usa odbc32.dll, che c'e' sempre; su macOS/Linux
81
+ # vuole unixODBC (libodbc), e l'import fallisce qui se manca.
82
+ raise Problema(f"pyodbc non utilizzabile ({e})") from e
83
+ driver = env.get("NOEVA_MSSQL_DRIVER") or s.get("driver") or "ODBC Driver 18 for SQL Server"
84
+ if driver not in pyodbc.drivers():
85
+ raise Problema(
86
+ f"il driver '{driver}' non e' installato su questo PC "
87
+ f"(driver ODBC presenti: {', '.join(pyodbc.drivers()) or 'nessuno'})"
88
+ )
89
+ return {
90
+ "host": s["host"],
91
+ "port": str(s.get("porta") or "1433"),
92
+ "database": s["database"],
93
+ # Utente e password entrambi assenti: il connettore usa
94
+ # Trusted_Connection=yes, cioe' l'utente Windows.
95
+ "user": utente or None,
96
+ "password": password or None,
97
+ "driver": driver,
98
+ "kwargs": {"TrustServerCertificate": "yes", **(s.get("opzioni") or {})},
99
+ }
100
+
101
+
102
+ def motore(s: dict):
103
+ from wren.engine import WrenEngine
104
+
105
+ import modello_locale
106
+
107
+ # Il modello non sta piu' solo nella cartella: repository aperto, versione
108
+ # pubblicata, cache (modello_locale.py, ADR 0005 punto 6).
109
+ progetto, origine = modello_locale.trova(s, (QUI / s["progetto"]).resolve())
110
+ s["_origine_modello"] = origine
111
+ modello_locale.log(f"{s['server']}: modello da {origine}")
112
+ mdl = progetto / "target" / "mdl.json"
113
+ if not mdl.is_file():
114
+ raise Problema(f"manca {mdl}: la cartella di NOEVA e' incompleta")
115
+ manifest = base64.b64encode(mdl.read_bytes()).decode()
116
+ if s["tipo"] == "duckdb":
117
+ dati = (QUI / s["dati"]).resolve()
118
+ if not any(dati.glob("*.duckdb")):
119
+ raise Problema(f"nessun .duckdb in {dati}")
120
+ # Il connettore DuckDB attacca in sola lettura ogni .duckdb della
121
+ # cartella, con alias = nome del file: e' il catalogo del modello.
122
+ eng = WrenEngine(manifest_str=manifest, data_source="duckdb",
123
+ connection_info={"url": str(dati), "format": "duckdb"})
124
+ else:
125
+ eng = WrenEngine(manifest_str=manifest, data_source="mssql", connection_info=connessione_mssql(s))
126
+ return progetto, eng
127
+
128
+
129
+ def servi(conf: dict, nome: str) -> None:
130
+ s = conf["sorgenti"][nome]
131
+ progetto, eng = motore(s)
132
+ from wren.mcp_server import ServeContext, build_server
133
+
134
+ server = build_server(ServeContext(project=progetto, engine=eng, allow_write=False, no_connect=False))
135
+ # FastMCP non espone un setter: nome e istruzioni stanno sul server di basso
136
+ # livello, ed e' quello che il client legge all'initialize.
137
+ server._mcp_server.name = s["server"]
138
+ server._mcp_server.instructions = s["istruzioni"]
139
+ try:
140
+ server.run(transport="stdio")
141
+ finally:
142
+ eng.close()
143
+
144
+
145
+ def verifica(conf: dict, nomi: list[str]) -> int:
146
+ """Controlli leggibili da chi installa: cosa c'e', cosa manca, cosa fare."""
147
+ print(f"{conf['nome']} · {QUI}")
148
+ falliti = 0
149
+ for nome in nomi:
150
+ s = conf["sorgenti"][nome]
151
+ print(f"\n{s['server']} ({nome}: {s.get('cosa', '')})")
152
+ try:
153
+ progetto, eng = motore(s)
154
+ except Problema as e:
155
+ print(f" NO {e}")
156
+ falliti += 1
157
+ continue
158
+ print(f" .. modello: {s.get('_origine_modello')}")
159
+ if s["tipo"] == "mssql":
160
+ c = eng.connection_info
161
+ print(f" ok driver {c.driver}")
162
+ print(f" .. server {c.host},{c.port} · database {c.database} · "
163
+ f"{'utente ' + c.user if c.user else 'autenticazione di Windows'}")
164
+ for titolo, sql in (s.get("verifiche") or {}).items():
165
+ try:
166
+ t = eng.query(sql, limit=5)
167
+ print(f" ok {titolo}: {t.to_pylist()[0] if t.num_rows else 'nessuna riga'}")
168
+ except Exception as e: # noqa: BLE001 - qui si mostra l'errore, qualunque sia
169
+ print(f" NO {titolo}: {str(e).strip().splitlines()[0][:400]}")
170
+ falliti += 1
171
+ break
172
+ eng.close()
173
+ print("\ntutto a posto" if not falliti else f"\n{falliti} sorgente/i con problemi")
174
+ return 1 if falliti else 0
175
+
176
+
177
+ def main(argv: list[str] | None = None) -> int:
178
+ ap = argparse.ArgumentParser(prog=pathlib.Path(sys.argv[0]).stem)
179
+ ap.add_argument("--sorgente", help="quale database servire (default: la prima di noeva.json)")
180
+ ap.add_argument("--verifica", action="store_true",
181
+ help="controlla configurazione e connessione (tutte le sorgenti, o quella data), poi esce")
182
+ a = ap.parse_args(argv)
183
+ try:
184
+ conf = configurazione()
185
+ nomi = list(conf["sorgenti"])
186
+ if a.sorgente and a.sorgente not in nomi:
187
+ raise Problema(f"sorgente '{a.sorgente}' non prevista: {', '.join(nomi)}")
188
+ if a.verifica:
189
+ # In una pipe (lo script PowerShell, o Claude Code che lo lancia) la
190
+ # console di Windows non e' UTF-8: meglio un ? che un'eccezione.
191
+ sys.stdout.reconfigure(errors="replace")
192
+ return verifica(conf, [a.sorgente] if a.sorgente else nomi)
193
+ servi(conf, a.sorgente or nomi[0])
194
+ except Problema as e:
195
+ # Claude Code mostra il server come "failed" e questo messaggio nel log
196
+ # dell'MCP; --verifica lo ripete in chiaro.
197
+ print(f"noeva: {e}", file=sys.stderr)
198
+ return 1
199
+ return 0
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ """Il modello di consegna di un progetto: quello che il servizio locale serve sui PC del cliente.
2
+
3
+ Il modello del repository e' costruito sui restore locali: la SQL dei modelli ERP
4
+ nomina <db>.dbo.<tabella>, i modelli DWH non hanno catalogo e lo prendevano dal
5
+ profilo. Qui le due cose diventano esplicite (`rinomina`, `catalogo_senza_nome`),
6
+ cosi' un login solo sul server del cliente legge entrambi i database; e della
7
+ conoscenza resta quella che il server serve.
8
+
9
+ Lo usano in tre (ADR 0005, punto 6), e per questo sta in un modulo solo:
10
+
11
+ - lib/mcp-locale/costruisci.py, che fino a oggi metteva il modello nella cartella
12
+ consegnata, con le trasformazioni di lib/mcp-locale/<id>.yml;
13
+ - lib/modello/pubblica.py, che dal repository del progetto lo compila e lo
14
+ pubblica su R2, con le trasformazioni di `consegna.yml` del repository;
15
+ - il servizio locale (lib/mcp-locale/server/noeva_mcp.py), che quando Claude Code
16
+ e' aperto nel repository compila il modello che sta li', non quello pubblicato.
17
+
18
+ Le trasformazioni:
19
+
20
+ rinomina: {erp_restore: erp_prod} <db>.dbo. nella SQL dei modelli
21
+ catalogo_senza_nome: dwh_prod il catalogo dei modelli che non ne hanno
22
+ modelli: [vendite, ...] solo questi (default: tutti)
23
+ query_esempio: true knowledge/sql si consegna (default: si')
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import pathlib
29
+ import shutil
30
+
31
+ import yaml
32
+
33
+ # Cosa del repository fa parte del modello: il resto (apps/, scripts/, docs/) no.
34
+ FILE_MODELLO = ["wren_project.yml", "relationships.yml", "models", "knowledge"]
35
+ # Le chiavi delle trasformazioni: il resto della configurazione di consegna
36
+ # (la connessione senza password, le verifiche, il ripiego offline) serve ai
37
+ # costruttori degli eseguibili, non al modello.
38
+ CHIAVI = ("rinomina", "catalogo_senza_nome", "modelli", "query_esempio")
39
+
40
+
41
+ def dump_yaml(dati, path: pathlib.Path) -> None:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ path.write_text(yaml.safe_dump(dati, allow_unicode=True, sort_keys=False, width=100), encoding="utf-8")
44
+
45
+
46
+ def leggi_config(radice: pathlib.Path) -> dict:
47
+ """consegna.yml del repository del progetto, intero, o {} se non c'e'.
48
+
49
+ E' lo schema di lib/mcp-locale/<id>.yml del monorepo: cliente, domanda_prova,
50
+ modello_r2, sorgenti (una connessione e le sue trasformazioni ciascuna),
51
+ snapshot. Nessun segreto: le password stanno in variabili d'ambiente."""
52
+ f = radice / "consegna.yml"
53
+ return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}) if f.is_file() else {}
54
+
55
+
56
+ def trasformazioni(conf: dict) -> dict:
57
+ """Le trasformazioni del modello pubblicato: quelle della sorgente viva.
58
+
59
+ Un progetto pubblica un modello solo; con piu' sorgenti vive (un modello per
60
+ database) il modello pubblicato non saprebbe quale scegliere."""
61
+ vive = conf.get("sorgenti") or {}
62
+ if len(vive) > 1:
63
+ raise ValueError(f"consegna.yml: {len(vive)} sorgenti vive; il modello pubblicato ne vuole una")
64
+ s = next(iter(vive.values()), conf)
65
+ return {k: s[k] for k in CHIAVI if k in s}
66
+
67
+
68
+ def leggi_consegna(radice: pathlib.Path) -> dict:
69
+ """Le trasformazioni di consegna.yml del repository, o {} (nessuna)."""
70
+ return trasformazioni(leggi_config(radice))
71
+
72
+
73
+ def modello_di_consegna(sorgente: pathlib.Path, dest: pathlib.Path, conf: dict) -> dict:
74
+ """Scrive in `dest` il modello di `sorgente` con le trasformazioni di `conf`.
75
+
76
+ Restituisce i conteggi delle trasformazioni, che chi costruisce stampa."""
77
+ rinomina = conf.get("rinomina") or {}
78
+ senza_nome = conf.get("catalogo_senza_nome")
79
+ scelti = set(conf.get("modelli") or []) # vuoto: tutti
80
+ conta = {"rinominati": 0, "catalogo": 0}
81
+ dest.mkdir(parents=True)
82
+ progetto = yaml.safe_load((sorgente / "wren_project.yml").read_text(encoding="utf-8"))
83
+ progetto.pop("profile", None) # niente profili sul PC del cliente: noeva.json
84
+ dump_yaml(progetto, dest / "wren_project.yml")
85
+ relazioni = yaml.safe_load((sorgente / "relationships.yml").read_text(encoding="utf-8")) or {}
86
+ if scelti:
87
+ relazioni["relationships"] = [r for r in relazioni.get("relationships") or []
88
+ if set(r["models"]) <= scelti]
89
+ dump_yaml(relazioni, dest / "relationships.yml")
90
+ for meta in sorted((sorgente / "models").glob("*/metadata.yml")):
91
+ if scelti and meta.parent.name not in scelti:
92
+ continue
93
+ m = yaml.safe_load(meta.read_text(encoding="utf-8"))
94
+ for vecchio, nuovo in rinomina.items():
95
+ if m.get("ref_sql") and f"{vecchio}.dbo." in m["ref_sql"]:
96
+ conta["rinominati"] += m["ref_sql"].count(f"{vecchio}.dbo.")
97
+ m["ref_sql"] = m["ref_sql"].replace(f"{vecchio}.dbo.", f"{nuovo}.dbo.")
98
+ tr = m.get("table_reference")
99
+ if tr is not None and not tr.get("catalog") and senza_nome:
100
+ tr["catalog"] = senza_nome
101
+ conta["catalogo"] += 1
102
+ dump_yaml(m, dest / "models" / meta.parent.name / "metadata.yml")
103
+ for extra in meta.parent.glob("*.sql"):
104
+ testo = extra.read_text(encoding="utf-8")
105
+ for vecchio, nuovo in rinomina.items():
106
+ testo = testo.replace(f"{vecchio}.dbo.", f"{nuovo}.dbo.")
107
+ (dest / "models" / meta.parent.name / extra.name).write_text(testo, encoding="utf-8")
108
+ # La conoscenza che il server serve (regole, query d'esempio, glossario).
109
+ # Restano fuori domande.yml e risposte-cliente.json: sono il lavoro in corso
110
+ # col cliente, che legge la pagina «Il progetto».
111
+ k = sorgente / "knowledge"
112
+ (dest / "knowledge").mkdir()
113
+ if (k / "knowledge.yml").is_file():
114
+ shutil.copy2(k / "knowledge.yml", dest / "knowledge" / "knowledge.yml")
115
+ cartelle = ["rules", "glossary", "caveats", "metrics"]
116
+ if conf.get("query_esempio", True):
117
+ cartelle.append("sql")
118
+ for cartella in cartelle:
119
+ if (k / cartella).is_dir():
120
+ shutil.copytree(k / cartella, dest / "knowledge" / cartella,
121
+ ignore=shutil.ignore_patterns(".gitkeep"))
122
+ return conta
123
+
124
+
125
+ def compila(dest: pathlib.Path) -> pathlib.Path:
126
+ """target/mdl.json del modello in `dest`, come `wren context build`, nel processo:
127
+ il servizio locale congelato non ha la CLI wren, ha il pacchetto."""
128
+ from wren.context import build_json, save_target
129
+
130
+ return save_target(build_json(dest), dest)
131
+
132
+
133
+ def numero_modelli(dest: pathlib.Path) -> int:
134
+ return len(json.loads((dest / "target" / "mdl.json").read_text(encoding="utf-8")).get("models") or [])
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """Il modello di consegna di un progetto, compilato e pubblicato su R2 (ADR 0005, punto 6).
3
+
4
+ python3 lib/modello/pubblica.py <repository> --url http://localhost:8789 [--workspace <id>] [--prova]
5
+ npx analytics modello --url ... (lo stesso, dal repository del progetto)
6
+
7
+ Fa, in quest'ordine:
8
+
9
+ 1. prende il modello dal commit (HEAD), non dal disco: wren_project.yml,
10
+ relationships.yml, models/, knowledge/ e consegna.yml. Quello che va ai PC
11
+ del cliente e' quello che e' in git;
12
+ 2. applica le trasformazioni di `consegna.yml` del repository (consegna.py);
13
+ 3. compila target/mdl.json come `wren context build`;
14
+ 4. carica i file su /_modello/<id>/<versione>/ e sposta il puntatore, con un
15
+ biglietto di tipo `modello`.
16
+
17
+ Il biglietto lo firma chi pubblica col segreto del contenitore (SEGRETO_DATI o
18
+ ~/.segreto-dati), o lo rilascia l'API NOEVA a chi ha la chiave di un membro del
19
+ workspace (NOEVA_API_KEY o ~/.noeva-api-key): e' la strada della CI.
20
+
21
+ La versione e' data e ora del commit piu' lo sha, come per le pagine: si legge
22
+ da quale commit viene il modello che un PC usa.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime as dt
28
+ import importlib.util
29
+ import json
30
+ import os
31
+ import pathlib
32
+ import subprocess
33
+ import sys
34
+ import tarfile
35
+ import tempfile
36
+ import time
37
+
38
+ QUI = pathlib.Path(__file__).resolve().parent
39
+ sys.path.insert(0, str(QUI))
40
+
41
+ from consegna import FILE_MODELLO, compila, leggi_consegna, modello_di_consegna, numero_modelli # noqa: E402
42
+
43
+ # Il biglietto e le chiamate sono quelli dello snapshot (lib/snapshot/pubblica.py).
44
+ # Si carica per percorso: si chiama pubblica.py anche lui, e un import per nome
45
+ # troverebbe questo file.
46
+ _spec = importlib.util.spec_from_file_location("snapshot_pubblica", QUI.parent / "snapshot" / "pubblica.py")
47
+ _snapshot = importlib.util.module_from_spec(_spec)
48
+ _spec.loader.exec_module(_snapshot)
49
+ chiama, sorgente_biglietti = _snapshot.chiama, _snapshot.sorgente_biglietti
50
+
51
+
52
+ def git(radice: pathlib.Path, *args: str) -> str:
53
+ return subprocess.run(["git", *args], cwd=radice, check=True, capture_output=True, text=True).stdout
54
+
55
+
56
+ def versione_di(radice: pathlib.Path) -> str:
57
+ sha, quando = git(radice, "log", "-1", "--format=%h%x09%cI").strip().split("\t")
58
+ t = dt.datetime.fromisoformat(quando).astimezone(dt.timezone.utc)
59
+ return f"{t:%Y%m%d-%H%M%S}-{sha}"
60
+
61
+
62
+ def estrai_da_head(radice: pathlib.Path, dest: pathlib.Path) -> None:
63
+ """I file del modello com'erano nel commit, senza toccare il disco del repository."""
64
+ presenti = [p for p in [*FILE_MODELLO, "consegna.yml"] if git(radice, "ls-tree", "--name-only", "HEAD", p).strip()]
65
+ mancanti = {"wren_project.yml", "relationships.yml", "models"} - set(presenti)
66
+ if mancanti:
67
+ raise SystemExit(f"nel commit mancano {sorted(mancanti)}: non e' il repository di un progetto con un modello")
68
+ archivio = subprocess.run(["git", "archive", "--format=tar", "HEAD", *presenti], cwd=radice,
69
+ check=True, capture_output=True).stdout
70
+ with tempfile.TemporaryFile() as t:
71
+ t.write(archivio)
72
+ t.seek(0)
73
+ with tarfile.open(fileobj=t) as tar:
74
+ tar.extractall(dest, filter="data")
75
+
76
+
77
+ def file_della_versione(dest: pathlib.Path) -> list[pathlib.Path]:
78
+ return sorted(p for p in dest.rglob("*") if p.is_file() and not p.name.startswith("."))
79
+
80
+
81
+ def main(argv: list[str]) -> int:
82
+ ap = argparse.ArgumentParser(prog="analytics modello", description=__doc__.split("\n\n")[0])
83
+ ap.add_argument("repository", nargs="?", default=".", help="il repository del progetto (default: qui)")
84
+ ap.add_argument("--url", default=os.environ.get("NOEVA_ANALYTICS_URL"), help="il contenitore")
85
+ ap.add_argument("--workspace", help="il workspace NOEVA che possiede il progetto (default: analytics.json)")
86
+ ap.add_argument("--prova", action="store_true", help="compila ed elenca, senza caricare")
87
+ a = ap.parse_args(argv[1:])
88
+
89
+ radice = pathlib.Path(a.repository).resolve()
90
+ analytics = json.loads((radice / "analytics.json").read_text(encoding="utf-8"))
91
+ progetto = analytics["progetto"]
92
+ workspace = a.workspace or analytics.get("workspace")
93
+ versione = versione_di(radice)
94
+
95
+ with tempfile.TemporaryDirectory() as tmp:
96
+ sorgente, dest = pathlib.Path(tmp) / "sorgente", pathlib.Path(tmp) / "modello"
97
+ estrai_da_head(radice, sorgente)
98
+ # Le trasformazioni di consegna.yml anche loro dal commit, non dal disco.
99
+ conta = modello_di_consegna(sorgente, dest, leggi_consegna(sorgente))
100
+ compila(dest)
101
+ file = file_della_versione(dest)
102
+ byte = sum(p.stat().st_size for p in file)
103
+ print(f"▸ {progetto}: modello {versione}, {numero_modelli(dest)} modelli, {len(file)} file, "
104
+ f"{byte // 1024} kB ({conta['rinominati']} riferimenti rinominati, {conta['catalogo']} col catalogo)")
105
+ if a.prova:
106
+ print("prova: niente caricato")
107
+ return 0
108
+ if not a.url:
109
+ raise SystemExit("serve --url: l'indirizzo del contenitore (in prova http://localhost:8789)")
110
+ if not workspace:
111
+ raise SystemExit("serve il workspace NOEVA che possiede il progetto: `workspace` in analytics.json, o --workspace")
112
+ # Il biglietto: col segreto del contenitore se c'e' (il Mac di chi amministra),
113
+ # altrimenti dall'API NOEVA con la chiave di un membro del workspace (la CI).
114
+ da = f"ci {os.environ.get('GITHUB_REPOSITORY', '')}".strip() if os.environ.get("GITHUB_ACTIONS") else "analytics modello"
115
+ b = sorgente_biglietti(progetto, da, workspace=workspace, tipo="modello", vicino=radice)(versione)
116
+ base = f"{a.url.rstrip('/')}/_modello/{progetto}/{versione}"
117
+ inizio = time.monotonic()
118
+ voci = []
119
+ for p in file:
120
+ rel = p.relative_to(dest).as_posix()
121
+ corpo = p.read_bytes()
122
+ chiama("PUT", f"{base}/{rel}", b, corpo)
123
+ voci.append({"file": rel, "byte": len(corpo)})
124
+ esito = chiama("POST", f"{base}/pubblica", b, json.dumps({"file": voci}).encode(),
125
+ intestazioni={"Content-Type": "application/json"})
126
+ print(f" pubblicato {esito['versione']}: {esito['file']} file, {time.monotonic() - inizio:.1f}s")
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main(sys.argv))