@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,495 @@
1
+ #!/usr/bin/env python
2
+ """Costruisce cio' che si consegna al cliente per usare un progetto con Claude sui suoi PC.
3
+
4
+ Spike del 20/10/2026 (docs/spike-mcpb-windows.md). Il cliente lavora con Claude
5
+ Code (CLI o tab Code di Claude Desktop) su una cartella che gli consegniamo al
6
+ posto del repository; il server MCP del progetto e' un eseguibile dentro la
7
+ cartella, che Claude Code avvia da solo via `.mcp.json`. Niente Docker, niente
8
+ Python, niente repository.
9
+
10
+ uv run --with pyyaml python lib/mcp-locale/costruisci.py project-<id>
11
+ uv run --with pyyaml python lib/mcp-locale/costruisci.py project-<id> --mcpb
12
+ uv run --with pyyaml python $(npx analytics percorso)/lib/mcp-locale/costruisci.py .
13
+
14
+ Il progetto e' una cartella del monorepo (project-<id>, con la configurazione in
15
+ lib/mcp-locale/<id>.yml) o il repository di un progetto (ADR 0005): la cartella
16
+ con analytics.json, la configurazione in consegna.yml e l'uscita in dist/ del
17
+ repository. Dal repository gira la CI che rilascia (rilascio-windows.yml): il
18
+ ripiego offline sullo snapshot vuole i dati, che in git non ci sono, e si
19
+ costruisce solo dove i dati ci sono.
20
+
21
+ Scrive in dist/mcp-locale/:
22
+
23
+ noeva-<id>/ LA CARTELLA DA CONSEGNARE
24
+ .mcp.json i due server: noeva-<id> (vivo) e noeva-<id>-snapshot
25
+ .claude/ settings.json (server abilitati, permessi)
26
+ CLAUDE.md le istruzioni per Claude (cartella/CLAUDE.md, compilato)
27
+ LEGGIMI.md l'installazione, per chi installa
28
+ imposta-password.ps1 chiede la password e la mette nella variabile dell'utente
29
+ installa.ps1 installa o aggiorna la cartella passo per passo, con l'esito finale
30
+ noeva/ l'eseguibile (PyInstaller, onedir) e cio' che legge:
31
+ noeva-<id>[.exe], _internal/, noeva.json
32
+ modello/ il modello completo con i nomi dei database di produzione
33
+ snapshot/ il ripiego offline: modello dello snapshot e dati/<id>.duckdb
34
+ noeva-<id>.zip la cartella zippata
35
+ noeva-<id>.mcpb con --mcpb: l'estensione Claude Desktop di tipo uv, solo snapshot
36
+
37
+ L'eseguibile si costruisce per il sistema su cui gira il builder: su macOS esce
38
+ un eseguibile macOS (serve a provare la ricetta), quello per i PC del cliente
39
+ va costruito su Windows (PyInstaller non fa cross-compilazione; LEGGIMI del
40
+ builder: docs/spike-mcpb-windows.md, «Costruire su Windows»). Il resto della
41
+ cartella e' lo stesso.
42
+
43
+ Prima di zippare prova l'eseguibile come Claude Code: --verifica sullo
44
+ snapshot, poi initialize, list_tools e un COUNT(*) per tabella via stdio
45
+ (prova.py).
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import datetime as dt
51
+ import json
52
+ import os
53
+ import pathlib
54
+ import shutil
55
+ import subprocess
56
+ import sys
57
+
58
+ import yaml
59
+
60
+ RADICE = pathlib.Path(__file__).resolve().parents[2]
61
+ QUI = pathlib.Path(__file__).resolve().parent
62
+ # Nel repository di un progetto diventa <repository>/dist/mcp-locale (progetto()).
63
+ USCITA = RADICE / "dist" / "mcp-locale"
64
+
65
+ # Le dipendenze dell'estensione .mcpb (solo snapshot, niente pyodbc); quelle
66
+ # dell'eseguibile stanno in ambiente/pyproject.toml.
67
+ DIPENDENZE = [
68
+ "wrenai[mcp]==0.15.0",
69
+ "wren-core-py==0.8.0",
70
+ "mcp[cli]>=1.19,<2",
71
+ "duckdb==1.5.6",
72
+ ]
73
+ PYTHON = "3.11"
74
+ MCPB = "@anthropic-ai/mcpb@2.1.2"
75
+ WINDOWS = sys.platform == "win32"
76
+
77
+
78
+ def esegui(cmd: list, cwd: pathlib.Path | None = None, **kw) -> subprocess.CompletedProcess:
79
+ print(" $", " ".join(str(c) for c in cmd), flush=True)
80
+ return subprocess.run([str(c) for c in cmd], cwd=cwd, check=True, **kw)
81
+
82
+
83
+ def dump_yaml(dati, path: pathlib.Path) -> None:
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ path.write_text(yaml.safe_dump(dati, allow_unicode=True, sort_keys=False, width=100), encoding="utf-8")
86
+
87
+
88
+ def pyproject(dest: pathlib.Path, nome: str, versione: str, deps: list[str]) -> None:
89
+ dest.mkdir(parents=True, exist_ok=True)
90
+ (dest / "pyproject.toml").write_text(
91
+ f'[project]\nname = "{nome}"\nversion = "{versione}"\n'
92
+ f'requires-python = "=={PYTHON}.*"\n'
93
+ "dependencies = [\n" + "".join(f' "{d}",\n' for d in deps) + "]\n"
94
+ "\n[tool.uv]\npackage = false\n",
95
+ encoding="utf-8",
96
+ )
97
+ # Claude Desktop installa le estensioni uv con un `uv sync` senza --python:
98
+ # senza questo file uv sceglierebbe il Python piu' recente ammesso.
99
+ (dest / ".python-version").write_text(PYTHON + "\n", encoding="utf-8")
100
+
101
+
102
+ # --- il modello dello snapshot -------------------------------------------------
103
+ def modello_snapshot(sorgente: pathlib.Path, dest: pathlib.Path, catalogo: str, manifesto: dict) -> list[dict]:
104
+ """Un progetto Wren su DuckDB dallo snapshot delle dashboard (ADR 0003)."""
105
+ conf = yaml.safe_load((sorgente / "snapshot.yml").read_text(encoding="utf-8"))
106
+ estrazioni = {e["nome"]: e for e in conf.get("estrazioni", [])}
107
+
108
+ dump_yaml({"schema_version": 5, "name": catalogo, "version": "1.0", "catalog": "wren",
109
+ "schema": "public", "data_source": "duckdb"}, dest / "wren_project.yml")
110
+ modelli = []
111
+ for m in manifesto["mdl"]["models"]:
112
+ nome = m["name"]
113
+ e = estrazioni.get(nome, {})
114
+ descr = (e.get("descrizione") or m.get("properties", {}).get("description") or "").strip()
115
+ if e.get("sql"):
116
+ descr += ("\n\nEstratta dal modello semantico completo con questa query "
117
+ "(i nomi sono quelli del modello, non di questo snapshot):\n" + e["sql"].strip())
118
+ modello = {
119
+ "name": nome,
120
+ "table_reference": {"catalog": catalogo, "schema": "main", "table": nome},
121
+ "columns": [{"name": c["name"], "type": c["type"]} for c in m["columns"]],
122
+ "properties": {"description": descr},
123
+ }
124
+ dump_yaml(modello, dest / "models" / nome / "metadata.yml")
125
+ modelli.append(modello)
126
+ dump_yaml({"relationships": manifesto["mdl"].get("relationships") or []}, dest / "relationships.yml")
127
+
128
+ # Le regole valgono anche qui (dicono cosa significano i numeri); le query di
129
+ # knowledge/sql no: sono scritte sul modello completo e qui fallirebbero.
130
+ k = dest / "knowledge"
131
+ (k / "rules").mkdir(parents=True)
132
+ if (sorgente / "knowledge" / "knowledge.yml").is_file():
133
+ shutil.copy2(sorgente / "knowledge" / "knowledge.yml", k / "knowledge.yml")
134
+ for f in sorted((sorgente / "knowledge" / "rules").glob("*.md")):
135
+ shutil.copy2(f, k / "rules" / f.name)
136
+ date = [f"- {manifesto.get('data_del_dato')}: vendite, rete, clienti, agenti (DWH)"]
137
+ for g, info in (manifesto.get("gruppi") or {}).items():
138
+ date.append(f"- {info.get('data_del_dato')}: {', '.join(info.get('estrazioni', []))} (gruppo {g})")
139
+ righe = "\n".join(f"- `{f['nome']}`: {f['righe']:,} righe".replace(",", ".") for f in manifesto["file"])
140
+ (k / "rules" / "00-snapshot.md").write_text(
141
+ f"""# Questo e' uno snapshot, non il database
142
+
143
+ Le tabelle interrogabili sono le estrazioni dello snapshot delle dashboard, non
144
+ le tabelle del modello completo (fatture_vendita, versamenti, giacenze...) di cui
145
+ parlano le altre regole. Le regole restano valide per capire cosa significano i
146
+ numeri; le query vanno scritte sulle tabelle qui sotto. La descrizione di ogni
147
+ tabella riporta la query con cui e' stata estratta dal modello completo: dice
148
+ quali filtri e quali regole sono gia' applicati.
149
+
150
+ Data del dato:
151
+ {chr(10).join(date)}
152
+
153
+ Snapshot generato il {manifesto.get('generato')}.
154
+
155
+ Tabelle:
156
+ {righe}
157
+
158
+ Se una domanda richiede un dettaglio che lo snapshot non ha (una singola
159
+ fattura, un movimento, un dato dopo le date sopra), dirlo invece di stimarlo.
160
+ """,
161
+ encoding="utf-8",
162
+ )
163
+ return modelli
164
+
165
+
166
+ # --- il modello completo, con i nomi di produzione ------------------------------
167
+ # Le trasformazioni (rinomina, catalogo_senza_nome) stanno in lib/modello/consegna.py:
168
+ # le usano anche il repository del progetto, che pubblica il modello su R2, e il
169
+ # servizio locale, che lo compila dal repository (ADR 0005, punto 6).
170
+ # lib/modello sta accanto a lib/mcp-locale sia nel monorepo sia nel pacchetto
171
+ # @noeva/analytics-core (contenuto/lib/).
172
+ sys.path.insert(0, str(QUI.parent / "modello"))
173
+ from consegna import leggi_config # noqa: E402
174
+ from consegna import modello_di_consegna as modello_vivo # noqa: E402
175
+
176
+
177
+ def progetto(arg: str, config: str | None = None) -> tuple[pathlib.Path, str, dict, pathlib.Path]:
178
+ """(sorgente, id, configurazione, uscita) di un progetto del monorepo o di un repository.
179
+
180
+ Un repository si riconosce da analytics.json: l'id viene da li', la
181
+ configurazione da consegna.yml, l'uscita e' la sua dist/."""
182
+ cartella = pathlib.Path(arg).expanduser()
183
+ if (cartella / "analytics.json").is_file():
184
+ sorgente = cartella.resolve()
185
+ ident = json.loads((sorgente / "analytics.json").read_text(encoding="utf-8"))["progetto"]
186
+ conf = yaml.safe_load(pathlib.Path(config).read_text(encoding="utf-8")) if config else leggi_config(sorgente)
187
+ return sorgente, ident, conf, sorgente / "dist" / "mcp-locale"
188
+ sorgente = (RADICE / arg).resolve()
189
+ ident = sorgente.name.removeprefix("project-")
190
+ conf_path = pathlib.Path(config) if config else QUI / f"{ident}.yml"
191
+ conf = yaml.safe_load(conf_path.read_text(encoding="utf-8")) if conf_path.is_file() else {}
192
+ return sorgente, ident, conf, RADICE / "dist" / "mcp-locale"
193
+
194
+
195
+ def relativo(p: pathlib.Path) -> str:
196
+ """Il percorso da stampare: relativo alla radice del monorepo o del repository, se ci sta sotto."""
197
+ for base in (USCITA.parents[1], RADICE):
198
+ try:
199
+ return str(p.relative_to(base))
200
+ except ValueError:
201
+ pass
202
+ return str(p)
203
+
204
+
205
+ # --- l'ambiente di build ---------------------------------------------------------
206
+ AMBIENTE = QUI / "ambiente" # pyproject.toml + uv.lock committati: le stesse versioni ovunque
207
+
208
+
209
+ def ambiente() -> pathlib.Path:
210
+ """La venv con le dipendenze esatte dell'eseguibile, PyInstaller compreso."""
211
+ esegui(["uv", "sync", "--frozen", "--project", AMBIENTE])
212
+ return AMBIENTE
213
+
214
+
215
+ def uv_run(amb: pathlib.Path, *args) -> None:
216
+ esegui(["uv", "run", "--frozen", "--project", amb, *args], cwd=amb)
217
+
218
+
219
+ def costruisci_duckdb(amb: pathlib.Path, db: pathlib.Path, parquet: pathlib.Path, tabelle: list[str]) -> None:
220
+ # Con la stessa versione di DuckDB che lo leggera': il formato del file la segue.
221
+ script = (
222
+ "import duckdb, sys, pathlib\n"
223
+ "db, cartella = sys.argv[1], pathlib.Path(sys.argv[2])\n"
224
+ "con = duckdb.connect(db)\n"
225
+ "for n in sys.argv[3:]:\n"
226
+ " p = str(cartella / (n + '.parquet')).replace(\"'\", \"''\")\n"
227
+ " con.execute(f'CREATE TABLE \"{n}\" AS SELECT * FROM read_parquet(\\'{p}\\')')\n"
228
+ "con.execute('CHECKPOINT'); con.close()\n"
229
+ )
230
+ db.parent.mkdir(parents=True, exist_ok=True)
231
+ uv_run(amb, "python", "-c", script, db, parquet, *tabelle)
232
+
233
+
234
+ def argomenti_pyinstaller(nome: str, build: pathlib.Path) -> list:
235
+ return [
236
+ "--noconfirm", "--clean", "--onedir", "--console",
237
+ "--name", nome,
238
+ "--distpath", build / "dist", "--workpath", build / "work", "--specpath", build,
239
+ # wren carica i connettori con importlib (wren/connector/factory.py) e
240
+ # sqlglot i dialetti: PyInstaller non li vede dagli import.
241
+ "--collect-submodules", "wren", "--collect-data", "wren",
242
+ "--collect-submodules", "sqlglot",
243
+ # pyodbc su macOS vuole unixODBC anche solo per essere analizzato: la
244
+ # build sul Mac serve a provare la ricetta sullo snapshot, il vivo no.
245
+ *(["--exclude-module", "pyodbc"] if sys.platform == "darwin" else ["--hidden-import", "pyodbc"]),
246
+ "--copy-metadata", "wrenai", "--copy-metadata", "mcp",
247
+ # memory (lancedb, onnx) non serve: recall ricade sulla ricerca testuale.
248
+ "--exclude-module", "lancedb", "--exclude-module", "onnxruntime",
249
+ "--exclude-module", "tkinter", "--exclude-module", "IPython",
250
+ # Nessuna dipendenza usa pkg_resources, ma se setuptools e' nella venv
251
+ # il suo hook parte all'avvio e muore su jaraco/backports.
252
+ "--exclude-module", "pkg_resources", "--exclude-module", "setuptools",
253
+ # Il modello si cerca anche nel repository e su R2 (server/modello_locale.py),
254
+ # e dal repository si compila con le trasformazioni di lib/modello/consegna.py.
255
+ "--paths", QUI / "server", "--paths", QUI.parent / "modello",
256
+ "--hidden-import", "modello_locale", "--hidden-import", "consegna",
257
+ QUI / "server" / "noeva_mcp.py",
258
+ ]
259
+
260
+
261
+ def costruisci_exe(amb: pathlib.Path | None, nome: str, build: pathlib.Path) -> pathlib.Path:
262
+ """L'eseguibile onedir: noeva-<id>[.exe] + _internal/.
263
+
264
+ Con l'ambiente uv del builder, oppure (amb None) con l'interprete che sta
265
+ girando: e' --solo-exe, per costruire dove uv non c'e' (il container)."""
266
+ if build.exists():
267
+ shutil.rmtree(build)
268
+ if amb is None:
269
+ esegui([sys.executable, "-m", "PyInstaller", *argomenti_pyinstaller(nome, build)])
270
+ else:
271
+ uv_run(amb, "pyinstaller", *argomenti_pyinstaller(nome, build))
272
+ return build / "dist" / nome
273
+
274
+
275
+ # --- la cartella da consegnare ---------------------------------------------------
276
+ def compila(modello: str, valori: dict) -> str:
277
+ testo = (QUI / "cartella" / modello).read_text(encoding="utf-8")
278
+ for k, v in valori.items():
279
+ testo = testo.replace("{{" + k + "}}", str(v))
280
+ return testo
281
+
282
+
283
+ def istruzioni_snapshot(cliente: str, manifesto: dict) -> str:
284
+ return (
285
+ f"NOEVA Analytics per {cliente}, SNAPSHOT delle dashboard (DWH al {manifesto.get('data_del_dato')}). "
286
+ "Prima di scrivere SQL leggi get_instructions: la regola 00-snapshot dice quali tabelle esistono e "
287
+ "a che data e' il dato. Dillo sempre nella risposta: e' il ripiego offline, non il database vivo."
288
+ )
289
+
290
+
291
+ def istruzioni_vivo(cliente: str, s: dict) -> str:
292
+ return (
293
+ f"NOEVA Analytics per {cliente}, dal vivo sul database di produzione ({s.get('cosa', '')}), in sola "
294
+ "lettura. Prima di scrivere SQL leggi get_instructions e describe_model: le regole di business stanno "
295
+ "li'. Valida con dry_run, poi run_sql con un limit; aggrega nel database invece di scaricare righe."
296
+ )
297
+
298
+
299
+ def main(argv: list[str]) -> int:
300
+ ap = argparse.ArgumentParser()
301
+ ap.add_argument("progetto", help="project-<id> del monorepo, o il repository del progetto (con analytics.json)")
302
+ ap.add_argument("--config", help="default: lib/mcp-locale/<id>.yml, o consegna.yml del repository")
303
+ ap.add_argument("--con-snapshot", action="store_true", help="anche il ripiego offline sullo snapshot")
304
+ ap.add_argument("--senza-exe", action="store_true", help="non costruire l'eseguibile (solo la cartella)")
305
+ ap.add_argument("--senza-prova", action="store_true", help="non provare l'eseguibile")
306
+ ap.add_argument("--mcpb", action="store_true", help="anche l'estensione Claude Desktop (uv, solo snapshot)")
307
+ ap.add_argument("--versione", default=None)
308
+ ap.add_argument("--solo-exe", metavar="CARTELLA",
309
+ help="solo l'eseguibile, con l'interprete corrente, in CARTELLA/dist/noeva-<id>")
310
+ a = ap.parse_args(argv[1:])
311
+ global USCITA
312
+ sorgente, ident, conf, USCITA = progetto(a.progetto, a.config)
313
+ if a.solo_exe:
314
+ print(costruisci_exe(None, f"noeva-{ident}", pathlib.Path(a.solo_exe).resolve()))
315
+ return 0
316
+
317
+ nome = f"noeva-{ident}"
318
+ cliente = conf.get("cliente") or ident.capitalize()
319
+ oggi = dt.date.today()
320
+ versione = a.versione or f"{oggi.year}.{oggi.month}.{oggi.day}"
321
+ vive = conf.get("sorgenti") or {}
322
+ con_snapshot = a.con_snapshot or a.mcpb or not vive
323
+ # Lo snapshot serve al ripiego offline e a dire nelle istruzioni a che data
324
+ # sono i dati; in un repository appena clonato (la CI) non c'e', e va bene
325
+ # finche' il ripiego non e' chiesto.
326
+ f_snapshot = sorgente / "apps" / "dati" / "snapshot.json"
327
+ manifesto = json.loads(f_snapshot.read_text(encoding="utf-8")) if f_snapshot.is_file() else {}
328
+ if con_snapshot and not manifesto:
329
+ raise SystemExit(f"il ripiego offline vuole lo snapshot ({relativo(f_snapshot)}), che qui non c'e': "
330
+ "si costruisce dove ci sono i dati, o senza --con-snapshot")
331
+
332
+ USCITA.mkdir(parents=True, exist_ok=True)
333
+ amb = ambiente()
334
+
335
+ cartella = USCITA / nome
336
+ print(f"{nome} -> {relativo(cartella)}")
337
+ if cartella.exists():
338
+ shutil.rmtree(cartella)
339
+ noeva = cartella / "noeva"
340
+ if not a.senza_exe:
341
+ shutil.copytree(costruisci_exe(amb, nome, USCITA / "_pyinstaller"), noeva)
342
+ else:
343
+ noeva.mkdir(parents=True)
344
+
345
+ # Le sorgenti: una connessione e un pezzo di modello ciascuna.
346
+ sorgenti = {}
347
+ for k, s in vive.items():
348
+ rel = "modello" if len(vive) == 1 else f"modello-{k}"
349
+ conta = modello_vivo(sorgente, noeva / rel, s)
350
+ print(f" {k}: {conta['rinominati']} riferimenti rinominati, {conta['catalogo']} modelli col catalogo "
351
+ f"{s.get('catalogo_senza_nome') or '-'}")
352
+ uv_run(amb, "wren", "context", "build", "--path", noeva / rel)
353
+ sorgenti[k] = {
354
+ "tipo": "mssql", "server": s["server"], "cosa": s.get("cosa", ""), "progetto": rel,
355
+ **{c: s[c] for c in ("host", "porta", "database", "utente", "password_env", "driver", "opzioni")
356
+ if c in s},
357
+ "istruzioni": istruzioni_vivo(cliente, s),
358
+ "verifiche": s.get("verifiche") or {},
359
+ # Il modello aggiornato dal contenitore, se il progetto lo pubblica (ADR 0005, punto 6).
360
+ **({"modello_r2": {"progetto": ident, **conf["modello_r2"]}} if conf.get("modello_r2") else {}),
361
+ }
362
+ if con_snapshot:
363
+ modelli = modello_snapshot(sorgente, noeva / "snapshot" / "modello", ident, manifesto)
364
+ costruisci_duckdb(amb, noeva / "snapshot" / "dati" / f"{ident}.duckdb",
365
+ sorgente / "apps" / "dati", [m["name"] for m in modelli])
366
+ uv_run(amb, "wren", "context", "build", "--path", noeva / "snapshot" / "modello")
367
+ sn = conf.get("snapshot") or {}
368
+ sorgenti["snapshot"] = {
369
+ "tipo": "duckdb", "server": sn.get("server", f"{nome}-snapshot"),
370
+ "cosa": "lo snapshot delle dashboard, offline", "progetto": "snapshot/modello",
371
+ "dati": "snapshot/dati", "istruzioni": istruzioni_snapshot(cliente, manifesto),
372
+ "verifiche": sn.get("verifiche") or {},
373
+ }
374
+ (noeva / "noeva.json").write_text(json.dumps({"nome": nome, "sorgenti": sorgenti},
375
+ ensure_ascii=False, indent=1), encoding="utf-8")
376
+
377
+ # .mcp.json e .claude/settings.json dalle sorgenti; il resto dai modelli in cartella/.
378
+ exe = f"noeva/{nome}.exe"
379
+ server = {
380
+ s["server"]: {"type": "stdio", "command": exe, "args": ["--sorgente", k], "env": {}}
381
+ for k, s in sorgenti.items()
382
+ }
383
+ (cartella / ".mcp.json").write_text(json.dumps({"mcpServers": server}, indent=2) + "\n", encoding="utf-8")
384
+ vivi = [s["server"] for s in sorgenti.values() if s["tipo"] == "mssql"]
385
+ ripieghi = [s["server"] for s in sorgenti.values() if s["tipo"] != "mssql"]
386
+ impostazioni = {
387
+ # I server della cartella partono senza chiedere conferma; il ripiego
388
+ # offline resta spento finche' qualcuno non lo accende da /mcp.
389
+ "enabledMcpjsonServers": vivi or ripieghi,
390
+ "disabledMcpjsonServers": ripieghi if vivi else [],
391
+ "permissions": {
392
+ "allow": [f"mcp__{n}__*" for n in server],
393
+ # La cartella noeva/ e' il motore: Claude la legge, non la cambia.
394
+ "deny": ["Edit(noeva/**)", "Write(noeva/**)"],
395
+ },
396
+ }
397
+ (cartella / ".claude").mkdir()
398
+ (cartella / ".claude" / "settings.json").write_text(json.dumps(impostazioni, indent=2) + "\n", encoding="utf-8")
399
+
400
+ primo = next(iter(vive.values()), {})
401
+ valori = {
402
+ "nome": nome, "cliente": cliente, "exe": exe, "exe_ps": f".\\noeva\\{nome}.exe",
403
+ "server": primo.get("server", nome), "server_snapshot": ", ".join(ripieghi) or "-",
404
+ "cosa": primo.get("cosa", ""),
405
+ "data_dwh": manifesto.get("data_del_dato") or "l'ultimo aggiornamento delle dashboard",
406
+ "host": primo.get("host", ""), "database": primo.get("database", ""),
407
+ "dwh": primo.get("catalogo_senza_nome", ""), "utente": primo.get("utente", ""),
408
+ "password_env": primo.get("password_env", ""), "versione": versione,
409
+ "chiave_env": (conf.get("modello_r2") or {}).get("chiave_env", ""),
410
+ "con_snapshot": "sì" if con_snapshot else "no",
411
+ # La domanda che installa.ps1 propone per la prova in Claude.
412
+ "domanda": conf.get("domanda_prova") or "Quali tabelle ci sono e quante righe hanno?",
413
+ }
414
+ for modello, dest in [("CLAUDE.md", "CLAUDE.md"), ("LEGGIMI.md", "LEGGIMI.md"),
415
+ ("imposta-password.ps1", "imposta-password.ps1"), ("installa.ps1", "installa.ps1")]:
416
+ testo = compila(modello, valori)
417
+ if dest.endswith(".ps1"):
418
+ # PowerShell 5.1 legge gli script senza BOM come ANSI: gli accenti
419
+ # diventerebbero mojibake.
420
+ (cartella / dest).write_text(testo, encoding="utf-8-sig", newline="\r\n")
421
+ else:
422
+ (cartella / dest).write_text(testo, encoding="utf-8")
423
+
424
+ if not a.senza_exe and not a.senza_prova:
425
+ binario = noeva / (f"{nome}.exe" if WINDOWS else nome)
426
+ print("prova dell'eseguibile")
427
+ # --verifica su tutte le sorgenti: quelle vive passano solo se driver,
428
+ # password e rete ci sono; sul Mac di sviluppo di solito no, e non e' un errore.
429
+ subprocess.run([str(binario), "--verifica"])
430
+ if con_snapshot:
431
+ uv_run(amb, "python", QUI / "prova.py", "--comando", binario, "--sorgente", "snapshot",
432
+ "--snapshot", sorgente / "apps" / "dati" / "snapshot.json")
433
+
434
+ archivio = shutil.make_archive(str(USCITA / nome), "zip", root_dir=USCITA, base_dir=nome)
435
+ print(f"cartella: {relativo(cartella)} · zip {pathlib.Path(archivio).stat().st_size // 1024 // 1024} MB")
436
+
437
+ if a.mcpb:
438
+ costruisci_mcpb(sorgente, nome, cliente, versione, manifesto, noeva, sorgenti["snapshot"])
439
+ return 0
440
+
441
+
442
+ # --- facoltativo: l'estensione Claude Desktop ------------------------------------
443
+ def costruisci_mcpb(sorgente, nome, cliente, versione, manifesto, noeva, snapshot: dict) -> None:
444
+ """L'estensione uv (manifest 0.4) sullo snapshot: il primo esito dello spike."""
445
+ est = USCITA / f"{nome}-mcpb"
446
+ if est.exists():
447
+ shutil.rmtree(est)
448
+ (est / "server").mkdir(parents=True)
449
+ shutil.copytree(noeva / "snapshot", est / "snapshot")
450
+ shutil.copy2(QUI / "server" / "noeva_mcp.py", est / "server" / "noeva_mcp.py")
451
+ logo = sorgente / "apps" / "assets" / "noeva-su-chiaro.png"
452
+ if logo.is_file():
453
+ shutil.copy2(logo, est / "icon.png")
454
+ if shutil.which("sips"): # solo macOS: il logo quadrato
455
+ subprocess.run(["sips", "-p", "512", "512", "--padColor", "FFFFFF", str(est / "icon.png")],
456
+ check=True, capture_output=True)
457
+ (est / "noeva.json").write_text(json.dumps({"nome": nome, "sorgenti": {"snapshot": snapshot}},
458
+ ensure_ascii=False, indent=1), encoding="utf-8")
459
+ pyproject(est, nome, versione, DIPENDENZE)
460
+ (est / ".mcpbignore").write_text(".venv/\n__pycache__/\n*.pyc\n.mcpbignore\n", encoding="utf-8")
461
+ manifest = {
462
+ "manifest_version": "0.4",
463
+ "name": nome,
464
+ "display_name": f"NOEVA Analytics · {cliente}",
465
+ "version": versione,
466
+ "description": f"I dati di {cliente} in Claude, sullo snapshot delle dashboard NOEVA Analytics.",
467
+ "author": {"name": "Gruppo 4D"},
468
+ "icon": "icon.png",
469
+ "server": {
470
+ "type": "uv",
471
+ "entry_point": "server/noeva_mcp.py",
472
+ "mcp_config": {
473
+ "command": "uv",
474
+ "args": ["run", "--directory", "${__dirname}", "--frozen", "--no-dev",
475
+ "--python", PYTHON, "python", "server/noeva_mcp.py", "--sorgente", "snapshot"],
476
+ "env": {"PYTHONIOENCODING": "utf-8"},
477
+ },
478
+ },
479
+ "tools_generated": True,
480
+ "compatibility": {"platforms": ["win32", "darwin"], "runtimes": {"python": f"=={PYTHON}.*"}},
481
+ "keywords": ["noeva", "analytics", cliente.lower()],
482
+ "license": "Proprietary",
483
+ }
484
+ (est / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
485
+ esegui(["uv", "lock", "--python", PYTHON], cwd=est)
486
+ esegui(["uv", "sync", "--frozen", "--python", PYTHON], cwd=est)
487
+ esegui(["uv", "run", "--frozen", "python", QUI / "prova.py", "--manifest", est, "--sorgente", "snapshot",
488
+ "--snapshot", sorgente / "apps" / "dati" / "snapshot.json"], cwd=est)
489
+ pacchetto = USCITA / f"{nome}.mcpb"
490
+ esegui(["npx", "-y", MCPB, "pack", est, pacchetto])
491
+ print(f"estensione: {relativo(pacchetto)} ({pacchetto.stat().st_size // 1024} KB)")
492
+
493
+
494
+ if __name__ == "__main__":
495
+ sys.exit(main(sys.argv))
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python
2
+ """Prova il server MCP di una consegna come lo avvierebbe il client, via stdio.
3
+
4
+ prova.py --comando dist/mcp-locale/noeva-<id>/noeva/noeva-<id> --sorgente vivo
5
+ prova.py --comando .../noeva-<id> --sorgente snapshot --snapshot project-<id>/apps/dati/snapshot.json
6
+ prova.py --manifest dist/mcp-locale/noeva-<id>-mcpb --sorgente snapshot --snapshot ...
7
+
8
+ --comando: un eseguibile, lanciato con `--sorgente <s>` (come da .mcp.json).
9
+ --manifest: la cartella di un'estensione .mcpb; comando e argomenti da
10
+ manifest.json, con ${__dirname} sostituito.
11
+
12
+ Fa initialize, list_tools, get_instructions; con --snapshot conta le righe di
13
+ ogni tabella contro snapshot.json; con --sql esegue quella query e la stampa.
14
+ Ambiente minimo (PATH, HOME, piu' le NOEVA_* e le *_PASSWORD presenti), come un
15
+ client che non passa altro. Esce con 1 se qualcosa non torna.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import pathlib
24
+ import sys
25
+ import time
26
+
27
+ from mcp import ClientSession, StdioServerParameters
28
+ from mcp.client.stdio import stdio_client
29
+
30
+
31
+ def parametri(a) -> StdioServerParameters:
32
+ env = {k: v for k, v in os.environ.items()
33
+ if k in ("PATH", "HOME", "SYSTEMROOT", "USERPROFILE", "TEMP", "TMP")
34
+ or k.startswith("NOEVA_") or k.endswith("_PASSWORD")}
35
+ if a.manifest:
36
+ est = pathlib.Path(a.manifest).resolve()
37
+ mc = json.loads((est / "manifest.json").read_text(encoding="utf-8"))["server"]["mcp_config"]
38
+ args = [x.replace("${__dirname}", str(est)) for x in mc["args"]]
39
+ env.update({k: v for k, v in mc.get("env", {}).items() if "${" not in v})
40
+ return StdioServerParameters(command=mc["command"], args=args, env=env)
41
+ return StdioServerParameters(command=str(pathlib.Path(a.comando).resolve()),
42
+ args=["--sorgente", a.sorgente], env=env)
43
+
44
+
45
+ async def prova(a) -> int:
46
+ t = time.time()
47
+ errori = 0
48
+ async with stdio_client(parametri(a)) as (r, w):
49
+ async with ClientSession(r, w) as s:
50
+ init = await s.initialize()
51
+ print(f" server: {init.serverInfo.name} · avvio {time.time() - t:.1f}s")
52
+ print(f" istruzioni: {init.instructions}")
53
+ strumenti = [x.name for x in (await s.list_tools()).tools]
54
+ print(f" strumenti ({len(strumenti)}): {', '.join(strumenti)}")
55
+ istr = await s.call_tool("get_instructions", {})
56
+ print(f" get_instructions: {len(istr.content[0].text)} caratteri")
57
+ modelli = await s.call_tool("list_models", {})
58
+ print(f" list_models: {modelli.content[0].text[:200].replace(chr(10), ' ')}…")
59
+ if a.snapshot:
60
+ manifesto = json.loads(pathlib.Path(a.snapshot).read_text(encoding="utf-8"))
61
+ for f in manifesto["file"]:
62
+ res = await s.call_tool("run_sql", {"sql": f"SELECT COUNT(*) AS n FROM {f['nome']}"})
63
+ try:
64
+ n = json.loads(res.content[0].text)["rows"][0]["n"]
65
+ except Exception:
66
+ n = None
67
+ if n != f["righe"]:
68
+ errori += 1
69
+ print(f" NON TORNA {f['nome']}: {res.content[0].text[:300]}")
70
+ print(f" COUNT(*) su {len(manifesto['file'])} tabelle: {len(manifesto['file']) - errori} tornano")
71
+ for sql in a.sql or []:
72
+ t = time.time()
73
+ res = await s.call_tool("run_sql", {"sql": sql, "limit": 20})
74
+ testo = res.content[0].text
75
+ print(f" run_sql ({time.time() - t:.1f}s) {sql[:90]}\n {testo[:700]}")
76
+ if res.isError:
77
+ errori += 1
78
+ return 1 if errori else 0
79
+
80
+
81
+ def main(argv: list[str]) -> int:
82
+ ap = argparse.ArgumentParser()
83
+ g = ap.add_mutually_exclusive_group(required=True)
84
+ g.add_argument("--comando")
85
+ g.add_argument("--manifest")
86
+ ap.add_argument("--sorgente", default="vivo")
87
+ ap.add_argument("--snapshot", help="snapshot.json: conta le righe di ogni tabella")
88
+ ap.add_argument("--sql", action="append", help="una query da eseguire (ripetibile)")
89
+ return asyncio.run(prova(ap.parse_args(argv[1:])))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main(sys.argv))