@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.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -0
- package/cli.js +34 -4
- package/contenuto/lib/mcp-locale/ambiente/.python-version +1 -0
- package/contenuto/lib/mcp-locale/ambiente/pyproject.toml +23 -0
- package/contenuto/lib/mcp-locale/ambiente/uv.lock +1103 -0
- package/contenuto/lib/mcp-locale/cartella/CLAUDE.md +52 -0
- package/contenuto/lib/mcp-locale/cartella/LEGGIMI.md +163 -0
- package/contenuto/lib/mcp-locale/cartella/imposta-password.ps1 +40 -0
- package/contenuto/lib/mcp-locale/cartella/installa.ps1 +286 -0
- package/contenuto/lib/mcp-locale/costruisci.py +495 -0
- package/contenuto/lib/mcp-locale/prova.py +93 -0
- package/contenuto/lib/mcp-locale/server/modello_locale.py +167 -0
- package/contenuto/lib/mcp-locale/server/noeva_mcp.py +203 -0
- package/contenuto/lib/modello/consegna.py +134 -0
- package/contenuto/lib/modello/pubblica.py +131 -0
- package/contenuto/lib/snapshot/ascolta.py +213 -0
- package/contenuto/lib/snapshot/impacchetta.py +184 -0
- package/contenuto/lib/snapshot/noeva_snapshot.py +107 -0
- package/contenuto/lib/snapshot/pubblica.py +35 -15
- package/contenuto/lib/snapshot/worker/installa.ps1 +340 -0
- package/core.js +18 -2
- package/modello/repository/github/workflows/ci.yml +53 -3
- package/modello/repository/github/workflows/rilascio-windows.yml +207 -0
- package/modello/repository/gitignore +2 -1
- package/package.json +2 -1
- package/pubblica.js +205 -0
|
@@ -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))
|