Lettore di metadati per file nativi Creo Parametric
Prototipo CLI in Python che estrae parametri e proprieta' dai file .prt senza avviare Creo e senza SDK commerciali. Solo libreria standard. I file nativi Creo sono container con header ASCII e un indice delle sezioni (UGC_TOC); la maggior parte delle sezioni non e' compressa e la tabella dei parametri e' leggibile direttamente. Ogni parametro compare in due rappresentazioni indipendenti (tabella estesa in LargeText e copia neutra in NeuPrtSld). Il lettore le confronta entrambe, e ne ricava: - confidence: 0.99 quando le due copie concordano, valori inferiori se il dato compare una volta sola o se divergono (status "conflict"); - owner: distingue i parametri del modello da quelli delle feature, che vivono solo nella copia neutra. I parametri numerici (PESO, DENSITA) usano una codifica a lunghezza variabile non ancora risolta: vengono riportati come "encoded_not_decoded" conservando i byte grezzi, senza inventare valori. Validato su file PART e PART/SHEETMETAL scritti da Creo 9.0.3.0. Assiemi, family table e versioni precedenti non sono ancora stati verificati. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# --- Modelli Creo -----------------------------------------------------------
|
||||||
|
# I file nativi sono disegni aziendali: non finiscono nel repository.
|
||||||
|
# I test di regressione girano sui file presenti in locale e si saltano da
|
||||||
|
# soli se la cartella e' vuota.
|
||||||
|
*.prt
|
||||||
|
*.prt.*
|
||||||
|
*.asm
|
||||||
|
*.asm.*
|
||||||
|
*.drw
|
||||||
|
*.drw.*
|
||||||
|
*.frm
|
||||||
|
*.frm.*
|
||||||
|
*.lay
|
||||||
|
*.lay.*
|
||||||
|
*.neu
|
||||||
|
*.stp
|
||||||
|
*.step
|
||||||
|
|
||||||
|
# --- Output generati --------------------------------------------------------
|
||||||
|
metadati.json
|
||||||
|
metadati.csv
|
||||||
|
*.metadata.json
|
||||||
|
/out/
|
||||||
|
/export/
|
||||||
|
|
||||||
|
# --- Python -----------------------------------------------------------------
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# --- Editor e sistema -------------------------------------------------------
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# creoparams
|
||||||
|
|
||||||
|
Estrae parametri e proprietà dai file nativi Creo Parametric (`.prt`, `.asm`,
|
||||||
|
`.drw`) **senza avviare Creo** e senza SDK commerciali. Solo Python 3, nessuna
|
||||||
|
dipendenza esterna.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
Un file alla volta — è il caso d'uso principale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m creoparams 9258400201.prt.12 # tabella a video
|
||||||
|
python3 -m creoparams 9258400201.prt.12 --quiet -o - # JSON su stdout
|
||||||
|
python3 -m creoparams 9258400201.prt.12 -o scheda.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Con un solo file l'output JSON è un **oggetto**; con più file una **lista**.
|
||||||
|
`-o -` scrive su stdout, quindi il comando si può mettere in pipe.
|
||||||
|
|
||||||
|
Un file indicato esplicitamente viene sempre elaborato così com'è: la scelta
|
||||||
|
automatica dell'ultima versione riguarda solo l'esplorazione di una cartella.
|
||||||
|
|
||||||
|
In lotto, quando serve:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m creoparams . --csv metadati.csv # cartella corrente
|
||||||
|
python3 -m creoparams /rete/archivio -r --csv indice.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Opzioni principali:
|
||||||
|
|
||||||
|
| Opzione | Effetto |
|
||||||
|
|---|---|
|
||||||
|
| `-o`, `--json FILE` | scrive il JSON su file, oppure su stdout con `-` |
|
||||||
|
| `--csv FILE` | tabella riepilogativa CSV (Excel, separatore `;`) |
|
||||||
|
| `-r`, `--recursive` | esplora le sottocartelle |
|
||||||
|
| `--all-versions` | elabora tutte le versioni (`.prt.1`, `.prt.2`, …) invece della sola più recente |
|
||||||
|
| `--features` | include i parametri delle feature (fori, lavorazioni) oltre a quelli di modello |
|
||||||
|
| `--system` | include i parametri generati da Creo (`PTC_*`, `SMT_*`) |
|
||||||
|
| `--quiet` | non stampa la tabella |
|
||||||
|
|
||||||
|
Codici di uscita: `0` estrazione riuscita, `1` nessun file trovato, `2` nessun
|
||||||
|
file elaborabile (file mancante o non nativo Creo). I messaggi diagnostici
|
||||||
|
vanno su stderr, così stdout resta pulito per le pipe.
|
||||||
|
|
||||||
|
Nella tabella a video, il marcatore a inizio riga indica l'affidabilità:
|
||||||
|
spazio = confermato in due rappresentazioni indipendenti, `?` = trovato una
|
||||||
|
volta sola, `~` = valore binario non decodificato, `!` = valori discordanti,
|
||||||
|
`-` = parametro atteso ma assente.
|
||||||
|
|
||||||
|
## Come funziona
|
||||||
|
|
||||||
|
Un file nativo Creo è un container con header ASCII in chiaro, un indice delle
|
||||||
|
sezioni (`UGC_TOC`) e un corpo binario. La maggior parte delle sezioni **non è
|
||||||
|
compressa**, e la tabella dei parametri è leggibile direttamente.
|
||||||
|
|
||||||
|
Ogni parametro è presente in due rappresentazioni indipendenti:
|
||||||
|
|
||||||
|
- la **tabella estesa** (sezione `LargeText`), che contiene i parametri di modello;
|
||||||
|
- la **copia neutra** (sezione `NeuPrtSld`), che contiene anche quelli delle feature.
|
||||||
|
|
||||||
|
Il lettore percorre entrambe e confronta i risultati. Da qui derivano due
|
||||||
|
informazioni che finiscono nell'output:
|
||||||
|
|
||||||
|
- `owner`: `model` se il parametro compare nella tabella estesa, `feature` se
|
||||||
|
vive solo nella copia neutra (è un parametro di un foro, non del pezzo);
|
||||||
|
- `confidence`: `0.99` se le due rappresentazioni concordano, valori più bassi
|
||||||
|
se il dato compare una volta sola o se le copie divergono (`status: conflict`).
|
||||||
|
|
||||||
|
## Formato di output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"file": { "name": "9258400201.prt.12", "sha256": "994dd3a8…", "size": 450797 },
|
||||||
|
"model": { "name": "9258400201", "kind": "PART", "creo_version": "9.0.3.0" },
|
||||||
|
"extraction": { "method": "ugc_native_reader", "holds_model_parameters": true },
|
||||||
|
"parameters": {
|
||||||
|
"DENOMINAZIONE": {
|
||||||
|
"type": "string", "owner": "model", "scope": "user",
|
||||||
|
"value": "Carter lato destro", "status": "found", "confidence": 0.99,
|
||||||
|
"sections": ["LargeText", "NeuPrtSld"]
|
||||||
|
},
|
||||||
|
"PESO": {
|
||||||
|
"type": "real", "status": "encoded_not_decoded",
|
||||||
|
"raw": "90f8382356ca2d", "driven_by": "value(d_val)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
L'`sha256` permette di verificare in seguito se un record estratto corrisponde
|
||||||
|
ancora al file da cui proviene.
|
||||||
|
|
||||||
|
## Limiti noti
|
||||||
|
|
||||||
|
**Parametri numerici non decodificati.** `PESO` e `DENSITA` usano una codifica
|
||||||
|
binaria a lunghezza variabile (7–8 byte osservati) che non è un double IEEE
|
||||||
|
lineare. Non vengono inventati valori: lo stato è `encoded_not_decoded` e i byte
|
||||||
|
grezzi sono conservati in `raw`, così da poter essere interpretati in seguito
|
||||||
|
senza rileggere l'archivio. Per decifrare la codifica servono alcuni valori di
|
||||||
|
riferimento letti da Creo.
|
||||||
|
|
||||||
|
**Disegni.** I `.drw` vengono riconosciuti e ne viene letto l'header, ma non
|
||||||
|
contengono la tabella parametri del modello: espongono note e cartiglio, con una
|
||||||
|
struttura diversa non ancora implementata. Il campo `holds_model_parameters`
|
||||||
|
lo dichiara esplicitamente.
|
||||||
|
|
||||||
|
**Copertura verificata.** Il lettore è stato validato su file `PART` e
|
||||||
|
`PART/SHEETMETAL` scritti da **Creo 9.0.3.0**. Non è stato provato su assiemi,
|
||||||
|
family table, né su file di versioni Creo precedenti. Prima di usarlo
|
||||||
|
sull'archivio storico, va verificato su un campione di file più vecchi.
|
||||||
|
|
||||||
|
**Family table.** Le istanze non hanno un file proprio: vivono nel generic. Su
|
||||||
|
un generic con family table il lettore restituirebbe i valori del generic, non
|
||||||
|
quelli dell'istanza. Nei file campione la sezione `FamilyInf` è vuota, quindi
|
||||||
|
il caso non è mai stato esercitato.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest test_regression -v
|
||||||
|
```
|
||||||
|
|
||||||
|
I test girano sui `.prt` presenti nella cartella e verificano che i parametri
|
||||||
|
aziendali obbligatori siano letti con confidenza alta. `KNOWN` in
|
||||||
|
`test_regression.py` contiene i valori verificati a mano: **è la rete di
|
||||||
|
sicurezza vera del progetto** e va estesa ogni volta che si confronta un nuovo
|
||||||
|
file con Creo.
|
||||||
|
|
||||||
|
## Struttura
|
||||||
|
|
||||||
|
| File | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| [creoparams/ugc.py](creoparams/ugc.py) | header e indice delle sezioni del container |
|
||||||
|
| [creoparams/params.py](creoparams/params.py) | riconoscimento dei record parametro nel binario |
|
||||||
|
| [creoparams/extract.py](creoparams/extract.py) | aggregazione, confidenza, record di output |
|
||||||
|
| [creoparams/cli.py](creoparams/cli.py) | riga di comando ed esportazioni |
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Lettura dei metadati dai file nativi Creo Parametric senza avviare Creo."""
|
||||||
|
|
||||||
|
from .extract import extract, latest_versions
|
||||||
|
from .ugc import NotACreoFile
|
||||||
|
|
||||||
|
__all__ = ["extract", "latest_versions", "NotACreoFile"]
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
|
except BrokenPipeError:
|
||||||
|
# Uscita normale quando l'output finisce in una pipe chiusa (| head).
|
||||||
|
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Interfaccia a riga di comando."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import ugc
|
||||||
|
from .extract import extract as extract_file
|
||||||
|
from .extract import latest_versions, split_filename
|
||||||
|
|
||||||
|
CREO_EXTENSIONS = (".prt", ".asm", ".drw", ".frm", ".lay")
|
||||||
|
|
||||||
|
#: Parametri aziendali attesi: se mancano vengono riportati esplicitamente
|
||||||
|
#: come "parameter_not_present" invece di sparire dall'output.
|
||||||
|
DEFAULT_EXPECTED = [
|
||||||
|
"CODICE",
|
||||||
|
"DENOMINAZIONE",
|
||||||
|
"REVISIONE",
|
||||||
|
"STAMPIGLIATURA",
|
||||||
|
"MATERIALE",
|
||||||
|
"DISEGNATORE",
|
||||||
|
"DATA",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_creo_file(path: str) -> bool:
|
||||||
|
stem, ext, _ = split_filename(os.path.basename(path))
|
||||||
|
return f".{ext}" in CREO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def collect(inputs: "list[str]", recursive: bool) -> "tuple[list[str], list[str]]":
|
||||||
|
"""Separa i file indicati esplicitamente da quelli trovati esplorando una
|
||||||
|
cartella: solo su questi ultimi ha senso scegliere l'ultima versione."""
|
||||||
|
explicit, discovered = [], []
|
||||||
|
for item in inputs:
|
||||||
|
if os.path.isdir(item):
|
||||||
|
walker = os.walk(item) if recursive else [(item, [], os.listdir(item))]
|
||||||
|
for root, _, files in walker:
|
||||||
|
discovered.extend(
|
||||||
|
os.path.join(root, f) for f in files if _is_creo_file(f)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
explicit.append(item)
|
||||||
|
return sorted(set(explicit)), sorted(set(discovered))
|
||||||
|
|
||||||
|
|
||||||
|
def _visible(record: dict, include_system: bool, include_features: bool = False) -> dict:
|
||||||
|
return {
|
||||||
|
name: entry
|
||||||
|
for name, entry in record["parameters"].items()
|
||||||
|
if (include_system or entry.get("scope") != "system")
|
||||||
|
and (include_features or entry.get("owner", "model") == "model")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _print_table(records: "list[dict]", stream, include_system: bool = False,
|
||||||
|
include_features: bool = False) -> None:
|
||||||
|
for record in records:
|
||||||
|
model = record["model"]
|
||||||
|
header = f"{record['file']['name']} [{model['kind']}"
|
||||||
|
if model["subtype"]:
|
||||||
|
header += f"/{model['subtype']}"
|
||||||
|
header += f"] Creo {model['creo_version'] or '?'}"
|
||||||
|
print(header, file=stream)
|
||||||
|
print("-" * len(header), file=stream)
|
||||||
|
|
||||||
|
if not record["extraction"]["holds_model_parameters"]:
|
||||||
|
print(" (i disegni non contengono la tabella parametri del modello)",
|
||||||
|
file=stream)
|
||||||
|
print(file=stream)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for name, entry in _visible(record, include_system, include_features).items():
|
||||||
|
status = entry.get("status")
|
||||||
|
if status == "found":
|
||||||
|
shown = entry["value"] if entry["value"] != "" else "(vuoto)"
|
||||||
|
mark = " " if entry.get("confidence", 0) >= 0.95 else "?"
|
||||||
|
elif status == "encoded_not_decoded":
|
||||||
|
shown = "<binario non decodificato>"
|
||||||
|
mark = "~"
|
||||||
|
elif status == "conflict":
|
||||||
|
shown = " | ".join(entry.get("candidates", []))
|
||||||
|
mark = "!"
|
||||||
|
else:
|
||||||
|
shown = "(assente)"
|
||||||
|
mark = "-"
|
||||||
|
print(f" {mark} {name:<22} {shown}", file=stream)
|
||||||
|
print(file=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(records: "list[dict]", path: str, include_system: bool = False,
|
||||||
|
include_features: bool = False) -> None:
|
||||||
|
names = sorted({n for r in records for n in _visible(r, include_system, include_features)})
|
||||||
|
columns = ["file", "model", "kind", "creo_version"] + names
|
||||||
|
|
||||||
|
with open(path, "w", newline="", encoding="utf-8-sig") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=columns, delimiter=";")
|
||||||
|
writer.writeheader()
|
||||||
|
for record in records:
|
||||||
|
row = {
|
||||||
|
"file": record["file"]["name"],
|
||||||
|
"model": record["model"]["name"],
|
||||||
|
"kind": record["model"]["kind"],
|
||||||
|
"creo_version": record["model"]["creo_version"],
|
||||||
|
}
|
||||||
|
for name in names:
|
||||||
|
entry = record["parameters"].get(name, {})
|
||||||
|
row[name] = entry.get("value") if entry.get("status") == "found" else ""
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="creoparams",
|
||||||
|
description="Estrae parametri e proprieta' dai file nativi Creo "
|
||||||
|
"(.prt/.asm/.drw) senza avviare Creo Parametric.",
|
||||||
|
epilog="Esempi:\n"
|
||||||
|
" creoparams 9258400201.prt.12 un singolo file\n"
|
||||||
|
" creoparams 9258400201.prt.12 -o - JSON su stdout\n"
|
||||||
|
" creoparams /rete/archivio -r --csv i.csv intera cartella",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("inputs", nargs="+", metavar="FILE|CARTELLA")
|
||||||
|
parser.add_argument("-o", "--json", metavar="FILE",
|
||||||
|
help="scrive il risultato in JSON; usare '-' per "
|
||||||
|
"stdout. Con un solo file emette un oggetto, "
|
||||||
|
"con piu' file una lista")
|
||||||
|
parser.add_argument("--csv", metavar="FILE",
|
||||||
|
help="scrive una tabella riepilogativa in CSV")
|
||||||
|
parser.add_argument("-r", "--recursive", action="store_true",
|
||||||
|
help="esplora le sottocartelle")
|
||||||
|
parser.add_argument("--all-versions", action="store_true",
|
||||||
|
help="elabora tutte le versioni (.prt.1, .prt.2, ...) "
|
||||||
|
"invece della sola piu' recente")
|
||||||
|
parser.add_argument("--system", action="store_true",
|
||||||
|
help="include anche i parametri generati da Creo "
|
||||||
|
"(PTC_*, SMT_*)")
|
||||||
|
parser.add_argument("--features", action="store_true",
|
||||||
|
help="include anche i parametri appartenenti alle "
|
||||||
|
"feature invece dei soli parametri di modello")
|
||||||
|
parser.add_argument("--quiet", action="store_true",
|
||||||
|
help="non stampa la tabella a video")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: "list[str] | None" = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
|
||||||
|
explicit, discovered = collect(args.inputs, args.recursive)
|
||||||
|
if not args.all_versions:
|
||||||
|
# Un file indicato a mano si elabora com'e': la scelta della versione
|
||||||
|
# piu' recente riguarda solo l'esplorazione di una cartella.
|
||||||
|
discovered = latest_versions(discovered)
|
||||||
|
paths = sorted(set(explicit) | set(discovered))
|
||||||
|
|
||||||
|
if not paths:
|
||||||
|
print("Nessun file Creo trovato.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
records, failures = [], []
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
records.append(extract_file(path, DEFAULT_EXPECTED))
|
||||||
|
except ugc.NotACreoFile as error:
|
||||||
|
failures.append((path, str(error)))
|
||||||
|
except OSError as error:
|
||||||
|
failures.append((path, str(error)))
|
||||||
|
|
||||||
|
if not args.quiet:
|
||||||
|
_print_table(records, sys.stdout, args.system, args.features)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
# Un solo file produce un oggetto, non una lista di un elemento:
|
||||||
|
# e' la forma piu' comoda da consumare a valle.
|
||||||
|
payload = records[0] if len(records) == 1 else records
|
||||||
|
if args.json == "-":
|
||||||
|
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
else:
|
||||||
|
with open(args.json, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||||
|
print(f"JSON scritto in {args.json}", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.csv:
|
||||||
|
_write_csv(records, args.csv, args.system, args.features)
|
||||||
|
print(f"CSV scritto in {args.csv}", file=sys.stderr)
|
||||||
|
|
||||||
|
for path, reason in failures:
|
||||||
|
print(f"SALTATO {os.path.basename(path)}: {reason}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Su un singolo file andato a buon fine il riepilogo e' solo rumore.
|
||||||
|
if len(paths) > 1 or failures:
|
||||||
|
print(
|
||||||
|
f"{len(records)} file elaborati, {len(failures)} saltati.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0 if records else 2
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Costruzione del record di metadati a partire da un file nativo Creo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import params, ugc
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
METHOD = "ugc_native_reader"
|
||||||
|
|
||||||
|
#: Prefissi dei parametri generati da Creo (impostazioni lamiera, materiale di
|
||||||
|
#: sistema, ...). Restano nell'output ma marcati come "system", per non
|
||||||
|
#: annegare i parametri aziendali nel rumore.
|
||||||
|
SYSTEM_PREFIXES = ("PTC_", "SMT_", "PRO_", "__")
|
||||||
|
|
||||||
|
#: Nome file Creo con numero di versione: "9258400201.prt.12".
|
||||||
|
_NAME_RE = re.compile(r"^(?P<stem>.+?)\.(?P<ext>prt|asm|drw|frm|lay)(?:\.(?P<ver>\d+))?$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def split_filename(filename: str) -> tuple[str, str, int | None]:
|
||||||
|
""""9258400201.prt.12" -> ("9258400201", "prt", 12)."""
|
||||||
|
match = _NAME_RE.match(filename)
|
||||||
|
if not match:
|
||||||
|
stem, _, ext = filename.rpartition(".")
|
||||||
|
return (stem or filename), ext.lower(), None
|
||||||
|
version = match.group("ver")
|
||||||
|
return match.group("stem"), match.group("ext").lower(), int(version) if version else None
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence(values: set, occurrences: int, sections: set) -> float:
|
||||||
|
if len(values) > 1:
|
||||||
|
return 0.40
|
||||||
|
if len(sections) >= 2:
|
||||||
|
return 0.99
|
||||||
|
if occurrences >= 2:
|
||||||
|
return 0.95
|
||||||
|
return 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate(occurrences: list) -> dict:
|
||||||
|
"""Raggruppa le comparse per nome e assegna stato e confidenza."""
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for occurrence in occurrences:
|
||||||
|
grouped[occurrence.name].append(occurrence)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for name, group in grouped.items():
|
||||||
|
kind = group[0].kind
|
||||||
|
sections = {o.section for o in group if o.section}
|
||||||
|
# Solo i parametri del modello compaiono nella tabella estesa; quelli
|
||||||
|
# che vivono unicamente nella copia neutra appartengono alle feature
|
||||||
|
# (fori, lavorazioni), non al modello.
|
||||||
|
owner = "model" if any(o.form == "extended" for o in group) else "feature"
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"type": kind,
|
||||||
|
"owner": owner,
|
||||||
|
"scope": "system" if name.startswith(SYSTEM_PREFIXES) else "user",
|
||||||
|
"occurrences": len(group),
|
||||||
|
"sections": sorted(sections),
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == "real":
|
||||||
|
raws = sorted({o.raw.hex() for o in group if o.raw})
|
||||||
|
expressions = sorted({o.expression for o in group if o.expression})
|
||||||
|
entry.update(
|
||||||
|
value=None,
|
||||||
|
status="encoded_not_decoded",
|
||||||
|
confidence=None,
|
||||||
|
raw=raws[0] if len(raws) == 1 else raws,
|
||||||
|
)
|
||||||
|
if expressions:
|
||||||
|
# Il valore non e' costante: lo produce una relazione.
|
||||||
|
entry["driven_by"] = expressions[0] if len(expressions) == 1 else expressions
|
||||||
|
else:
|
||||||
|
values = {o.value for o in group}
|
||||||
|
entry["confidence"] = _confidence(values, len(group), sections)
|
||||||
|
if len(values) == 1:
|
||||||
|
entry.update(value=group[0].value, status="found")
|
||||||
|
else:
|
||||||
|
entry.update(
|
||||||
|
value=None,
|
||||||
|
status="conflict",
|
||||||
|
candidates=sorted(values),
|
||||||
|
)
|
||||||
|
result[name] = entry
|
||||||
|
|
||||||
|
return dict(sorted(result.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def extract(path: str, expected: "list[str] | None" = None) -> dict:
|
||||||
|
"""Estrae i metadati da un singolo file. Solleva ugc.NotACreoFile se il
|
||||||
|
file non e' un nativo Creo riconoscibile."""
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
data = handle.read()
|
||||||
|
|
||||||
|
container = ugc.parse(data)
|
||||||
|
occurrences = params.iter_occurrences(data)
|
||||||
|
for occurrence in occurrences:
|
||||||
|
occurrence.section = container.section_at(occurrence.offset)
|
||||||
|
|
||||||
|
filename = os.path.basename(path)
|
||||||
|
stem, ext, version = split_filename(filename)
|
||||||
|
|
||||||
|
parameters = _aggregate(occurrences)
|
||||||
|
|
||||||
|
# I disegni non contengono la tabella parametri del modello: segnalarne
|
||||||
|
# l'assenza come "parametro mancante" sarebbe fuorviante.
|
||||||
|
holds_parameters = container.base_kind in ("PART", "ASSEMBLY")
|
||||||
|
if holds_parameters:
|
||||||
|
for name in expected or []:
|
||||||
|
parameters.setdefault(
|
||||||
|
name, {"value": None, "status": "parameter_not_present", "type": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"source_file": os.path.abspath(path),
|
||||||
|
"file": {
|
||||||
|
"name": filename,
|
||||||
|
"size": len(data),
|
||||||
|
"sha256": hashlib.sha256(data).hexdigest(),
|
||||||
|
"modified": datetime.fromtimestamp(
|
||||||
|
os.path.getmtime(path), timezone.utc
|
||||||
|
).isoformat(),
|
||||||
|
"creo_version_number": version,
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": container.common_name.rsplit(".", 1)[0] or stem,
|
||||||
|
"file_name": container.common_name or f"{stem}.{ext}",
|
||||||
|
"kind": container.base_kind,
|
||||||
|
"subtype": container.subtype,
|
||||||
|
"creo_version": container.creo_version,
|
||||||
|
"banner": container.banner,
|
||||||
|
},
|
||||||
|
"extraction": {
|
||||||
|
"method": METHOD,
|
||||||
|
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"sections": len(container.sections),
|
||||||
|
"parameters_found": len(occurrences),
|
||||||
|
"holds_model_parameters": holds_parameters,
|
||||||
|
},
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def latest_versions(paths: "list[str]") -> "list[str]":
|
||||||
|
"""Tra piu' versioni dello stesso modello tiene solo la piu' recente."""
|
||||||
|
best: dict = {}
|
||||||
|
for path in paths:
|
||||||
|
stem, ext, version = split_filename(os.path.basename(path))
|
||||||
|
key = (os.path.dirname(os.path.abspath(path)), stem, ext)
|
||||||
|
current = best.get(key)
|
||||||
|
if current is None or (version or -1) > (current[0] or -1):
|
||||||
|
best[key] = (version, path)
|
||||||
|
return sorted(path for _, path in best.values())
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Estrazione della tabella parametri dal corpo di un file nativo Creo.
|
||||||
|
|
||||||
|
I parametri sono memorizzati in chiaro, con una struttura regolare che si
|
||||||
|
ripete per ogni voce:
|
||||||
|
|
||||||
|
f6 e3 <NOME> 00 <slot> 88 00 e3 <tipo> <valore> ...
|
||||||
|
|
||||||
|
dove `tipo` e' 0x33 ('3') per le stringhe e 0x32 ('2') per i numerici.
|
||||||
|
Ogni parametro compare due volte di seguito (valore corrente e valore di
|
||||||
|
riferimento) e l'intera tabella e' duplicata in due sezioni del file
|
||||||
|
(tipicamente `LargeText` e `NeuPrtSld`). Usiamo questa ridondanza per
|
||||||
|
assegnare un livello di confidenza a ogni valore.
|
||||||
|
|
||||||
|
I valori stringa sono leggibili direttamente. I numerici usano una codifica
|
||||||
|
binaria a lunghezza variabile (7-8 byte osservati) che non e' un double IEEE
|
||||||
|
lineare e che qui non viene decodificata: ne conserviamo i byte grezzi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
REC_START = b"\xf6\xe3"
|
||||||
|
VALUE_LEAD = b"\x88\x00\xe3"
|
||||||
|
NUM_LEAD = b"\xe3\x32"
|
||||||
|
|
||||||
|
KIND_STRING = 0x33
|
||||||
|
KIND_NUMERIC = 0x32
|
||||||
|
|
||||||
|
#: Marcatore di espressione: il valore non e' costante ma prodotto da una
|
||||||
|
#: relazione del modello (es. PESO guidato da "value(d_val)").
|
||||||
|
EXPR_LEAD = b"\xe0\x02"
|
||||||
|
|
||||||
|
MAX_STRING = 256
|
||||||
|
MAX_NUMERIC_SPAN = 48
|
||||||
|
|
||||||
|
# Byte ammessi in un valore stringa: ASCII stampabile piu' l'intervallo alto
|
||||||
|
# per le lettere accentate, esclusi i byte usati come token dal formato.
|
||||||
|
_TOKENS = {0xE1, 0xE3, 0xF6, 0xF7}
|
||||||
|
_STRING_BYTES = frozenset(
|
||||||
|
set(range(0x20, 0x7F)) | (set(range(0xA0, 0x100)) - _TOKENS)
|
||||||
|
)
|
||||||
|
|
||||||
|
_NAME = rb"([A-Za-z_][A-Za-z0-9_.\-]{0,63})\x00"
|
||||||
|
|
||||||
|
#: Forma estesa, nella tabella parametri principale (sezione LargeText).
|
||||||
|
_RECORD_RE = re.compile(
|
||||||
|
REC_START + _NAME
|
||||||
|
+ rb"(.)" # byte di slot/identificativo
|
||||||
|
+ re.escape(VALUE_LEAD)
|
||||||
|
+ rb"([\x32\x33])", # tipo del valore
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Forma compatta, nella rappresentazione neutra (sezione NeuPrtSld). Ripete
|
||||||
|
#: gli stessi parametri e ci serve come verifica incrociata. Il prefisso che
|
||||||
|
#: separa i record varia a seconda dei campi accessori presenti (ref_id,
|
||||||
|
#: unit, designated, ...), quindi ci ancoriamo solo al nome seguito dal tipo.
|
||||||
|
_NEUTRAL_RE = re.compile(
|
||||||
|
rb"\xe3" + _NAME + rb"\xe2([\x32\x33])",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Occurrence:
|
||||||
|
"""Una singola comparsa di un parametro nel file."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str # "string" | "real"
|
||||||
|
value: str | None # None se non decodificato
|
||||||
|
raw: bytes | None # byte grezzi, per i numerici
|
||||||
|
offset: int
|
||||||
|
section: str = ""
|
||||||
|
expression: str | None = None # relazione che pilota il valore
|
||||||
|
form: str = "extended" # "extended" (tabella modello) | "neutral"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def decoded(self) -> bool:
|
||||||
|
return self.value is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_text(raw: bytes) -> str:
|
||||||
|
"""I valori sono scritti in UTF-8; i file piu' vecchi possono usare la
|
||||||
|
codepage Windows."""
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return raw.decode("cp1252", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_string(data: bytes, pos: int) -> tuple[str, int] | None:
|
||||||
|
"""Legge un valore stringa terminato da NUL. Ritorna (valore, fine)."""
|
||||||
|
# Un token f7 xx puo' precedere il valore (flag di stato del parametro).
|
||||||
|
if data[pos : pos + 1] == b"\xf7":
|
||||||
|
pos += 2
|
||||||
|
|
||||||
|
end = pos
|
||||||
|
limit = min(len(data), pos + MAX_STRING)
|
||||||
|
while end < limit and data[end] != 0x00:
|
||||||
|
if data[end] not in _STRING_BYTES:
|
||||||
|
return None
|
||||||
|
end += 1
|
||||||
|
if end >= limit:
|
||||||
|
return None # nessun terminatore: non e' un record valido
|
||||||
|
|
||||||
|
return _decode_text(data[pos:end]), end + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _read_numeric(data: bytes, pos: int) -> tuple[bytes, int] | None:
|
||||||
|
"""Isola i byte grezzi di un valore numerico.
|
||||||
|
|
||||||
|
Sfrutta il fatto che il valore e' immediatamente ripetuto dopo un secondo
|
||||||
|
marcatore `e3 32`: proviamo ogni posizione candidata e teniamo la prima in
|
||||||
|
cui la ripetizione combacia davvero.
|
||||||
|
"""
|
||||||
|
start = pos
|
||||||
|
# Un token f7 xx puo' precedere il valore.
|
||||||
|
if data[start : start + 1] == b"\xf7":
|
||||||
|
start += 2
|
||||||
|
|
||||||
|
search_end = min(len(data), start + MAX_NUMERIC_SPAN)
|
||||||
|
cursor = start
|
||||||
|
while True:
|
||||||
|
repeat = data.find(NUM_LEAD, cursor, search_end)
|
||||||
|
if repeat < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = data[start:repeat]
|
||||||
|
# Un token di separazione puo' chiudere il primo valore.
|
||||||
|
if candidate[-2:-1] == b"\xf7":
|
||||||
|
candidate = candidate[:-2]
|
||||||
|
|
||||||
|
after = repeat + len(NUM_LEAD)
|
||||||
|
if candidate and data[after : after + len(candidate)] == candidate:
|
||||||
|
return candidate, after + len(candidate)
|
||||||
|
|
||||||
|
cursor = repeat + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _read_expression(data: bytes, pos: int) -> tuple[str | None, int]:
|
||||||
|
"""Se il valore e' pilotato da una relazione, ne legge il testo."""
|
||||||
|
if data[pos : pos + len(EXPR_LEAD)] != EXPR_LEAD:
|
||||||
|
return None, pos
|
||||||
|
parsed = _read_string(data, pos + len(EXPR_LEAD))
|
||||||
|
if parsed is None:
|
||||||
|
return None, pos
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def iter_occurrences(data: bytes) -> "list[Occurrence]":
|
||||||
|
"""Scorre il file e restituisce ogni comparsa di parametro trovata.
|
||||||
|
|
||||||
|
Vengono percorse entrambe le rappresentazioni presenti nel file: la
|
||||||
|
tabella parametri estesa e la copia neutra. La ridondanza serve poi ad
|
||||||
|
assegnare la confidenza in `extract`.
|
||||||
|
"""
|
||||||
|
found: list[Occurrence] = []
|
||||||
|
|
||||||
|
for regex, kind_group, form in (
|
||||||
|
(_RECORD_RE, 3, "extended"),
|
||||||
|
(_NEUTRAL_RE, 2, "neutral"),
|
||||||
|
):
|
||||||
|
for match in regex.finditer(data):
|
||||||
|
name = match.group(1).decode("ascii")
|
||||||
|
kind_byte = match.group(kind_group)[0]
|
||||||
|
pos = match.end()
|
||||||
|
|
||||||
|
if kind_byte == KIND_STRING:
|
||||||
|
parsed = _read_string(data, pos)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
found.append(
|
||||||
|
Occurrence(
|
||||||
|
name, "string", parsed[0], None, match.start(), form=form
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expression, pos = _read_expression(data, pos)
|
||||||
|
parsed = _read_numeric(data, pos)
|
||||||
|
found.append(
|
||||||
|
Occurrence(
|
||||||
|
name,
|
||||||
|
"real",
|
||||||
|
None,
|
||||||
|
parsed[0] if parsed else None,
|
||||||
|
match.start(),
|
||||||
|
expression=expression,
|
||||||
|
form=form,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
found.sort(key=lambda o: o.offset)
|
||||||
|
return found
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Lettura del container UGC dei file nativi Creo (.prt, .asm, .drw).
|
||||||
|
|
||||||
|
Un file nativo Creo si apre con un header ASCII in chiaro, seguito da un
|
||||||
|
indice delle sezioni (UGC_TOC) e dal corpo binario. Solo alcune sezioni sono
|
||||||
|
compresse: la tabella dei parametri non lo e', ed e' quello che sfruttiamo.
|
||||||
|
|
||||||
|
Struttura osservata su Creo 9.0.3.0:
|
||||||
|
|
||||||
|
#UGC:2 PART/SHEETMETAL 2038 1760 805 1 1 15 4000 2022414 000002d7 \\
|
||||||
|
#- VERS 0 0 \\
|
||||||
|
#- CMNM 00e9258400200.prt \\
|
||||||
|
#-END_OF_UGC_HEADER
|
||||||
|
#Creo TM 9.0 (c) 2026 by PTC Inc. All Rights Reserved. 9.0.3.0
|
||||||
|
#UGC_TOC 2 32 81 17###...
|
||||||
|
ND:0:Model_L05_PX:1 ab4 440 557 2038 a 1 b715 85d3###...
|
||||||
|
BasicData 2c3b 15767 1575c 2038 9 -1 e3ea 48f0###...
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
HEADER_END = b"#-END_OF_UGC_HEADER"
|
||||||
|
TOC_MARKER = b"#UGC_TOC"
|
||||||
|
MAGIC = b"#UGC:"
|
||||||
|
|
||||||
|
# Quanto leggiamo per cercare header e TOC. L'indice sta sempre nei primi KB.
|
||||||
|
_HEAD_WINDOW = 64 * 1024
|
||||||
|
|
||||||
|
_BANNER_RE = re.compile(r"^#(Creo|Pro/ENGINEER).*", re.IGNORECASE)
|
||||||
|
_VERSION_RE = re.compile(r"(\d+(?:\.\d+){1,3})\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Section:
|
||||||
|
"""Una voce dell'indice UGC_TOC.
|
||||||
|
|
||||||
|
`alt_size` e' il terzo campo numerico. Per le sezioni compresse e' maggiore
|
||||||
|
di `size` (dimensione decompressa); per quelle non compresse e' leggermente
|
||||||
|
minore. Non lo interpretiamo oltre: ci serve solo come indizio di
|
||||||
|
compressione.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
offset: int
|
||||||
|
size: int
|
||||||
|
alt_size: int
|
||||||
|
extra: tuple = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end(self) -> int:
|
||||||
|
return self.offset + self.size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def looks_compressed(self) -> bool:
|
||||||
|
return self.alt_size > self.size
|
||||||
|
|
||||||
|
def contains(self, pos: int) -> bool:
|
||||||
|
return self.offset <= pos < self.end
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Container:
|
||||||
|
"""Un file nativo Creo aperto e con header/TOC interpretati."""
|
||||||
|
|
||||||
|
data: bytes
|
||||||
|
kind: str = "" # PART, PART/SHEETMETAL, DRAWING, ASSEMBLY...
|
||||||
|
creo_version: str = "" # es. "9.0.3.0"
|
||||||
|
banner: str = "" # riga di copyright completa
|
||||||
|
common_name: str = "" # nome originale del modello (campo CMNM)
|
||||||
|
header: dict = field(default_factory=dict)
|
||||||
|
sections: list = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_kind(self) -> str:
|
||||||
|
return self.kind.split("/", 1)[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtype(self) -> str:
|
||||||
|
parts = self.kind.split("/", 1)
|
||||||
|
return parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
def section_at(self, pos: int) -> str:
|
||||||
|
for section in self.sections:
|
||||||
|
if section.contains(pos):
|
||||||
|
return section.name
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class NotACreoFile(ValueError):
|
||||||
|
"""Il file non ha un header UGC riconoscibile."""
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cmnm(raw: str) -> str:
|
||||||
|
"""Il campo CMNM e' prefissato da 3 cifre esadecimali con la lunghezza.
|
||||||
|
|
||||||
|
Esempio: "00e9258400200.prt" -> 0x00e = 14 = len("9258400200.prt").
|
||||||
|
Se il prefisso non torna, restituiamo il valore grezzo.
|
||||||
|
"""
|
||||||
|
if len(raw) > 3:
|
||||||
|
try:
|
||||||
|
declared = int(raw[:3], 16)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
if declared == len(raw) - 3:
|
||||||
|
return raw[3:]
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_toc_line(line: str) -> Section | None:
|
||||||
|
# Le righe del TOC sono paddate a destra con '#'.
|
||||||
|
line = line.rstrip("#").strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
offset = int(parts[1], 16)
|
||||||
|
size = int(parts[2], 16)
|
||||||
|
alt_size = int(parts[3], 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return Section(parts[0], offset, size, alt_size, tuple(parts[4:]))
|
||||||
|
|
||||||
|
|
||||||
|
def parse(data: bytes) -> Container:
|
||||||
|
"""Interpreta header e indice. Non tocca il corpo binario."""
|
||||||
|
if not data.startswith(MAGIC):
|
||||||
|
raise NotACreoFile("header UGC assente (i primi byte non sono '#UGC:')")
|
||||||
|
|
||||||
|
head_end = data.find(HEADER_END)
|
||||||
|
if head_end < 0:
|
||||||
|
raise NotACreoFile("marcatore di fine header UGC non trovato")
|
||||||
|
|
||||||
|
container = Container(data=data)
|
||||||
|
window = data[: max(head_end + _HEAD_WINDOW, head_end)].decode(
|
||||||
|
"latin-1", errors="replace"
|
||||||
|
)
|
||||||
|
lines = window.split("\n")
|
||||||
|
|
||||||
|
in_toc = False
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
stripped = line.rstrip().rstrip("\\").rstrip()
|
||||||
|
|
||||||
|
if index == 0:
|
||||||
|
# "#UGC:2 PART/SHEETMETAL 2038 1760 ..."
|
||||||
|
tokens = stripped.split()
|
||||||
|
container.kind = tokens[1] if len(tokens) > 1 else ""
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_toc:
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
break # fine dell'indice, inizia il corpo
|
||||||
|
section = _parse_toc_line(stripped)
|
||||||
|
if section:
|
||||||
|
container.sections.append(section)
|
||||||
|
# NEXT_TOC_ENTRY non punta a un secondo indice ASCII: il corpo
|
||||||
|
# prosegue con blocchi marcati inline. Ci fermiamo qui.
|
||||||
|
if section.name == "NEXT_TOC_ENTRY":
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("#- "):
|
||||||
|
key, _, value = stripped[3:].partition(" ")
|
||||||
|
value = value.strip()
|
||||||
|
container.header[key] = value
|
||||||
|
if key == "CMNM":
|
||||||
|
container.common_name = _decode_cmnm(value)
|
||||||
|
elif stripped.startswith(TOC_MARKER.decode()):
|
||||||
|
in_toc = True
|
||||||
|
elif _BANNER_RE.match(stripped):
|
||||||
|
container.banner = stripped.lstrip("#").strip()
|
||||||
|
match = _VERSION_RE.search(container.banner)
|
||||||
|
if match:
|
||||||
|
container.creo_version = match.group(1)
|
||||||
|
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
|
def read(path) -> Container:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
return parse(handle.read())
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Test di regressione sui file campione presenti nella cartella.
|
||||||
|
|
||||||
|
Il parser si appoggia alla struttura binaria del formato Creo: qualsiasi
|
||||||
|
modifica alle espressioni di ricerca va verificata contro file reali. Il test
|
||||||
|
si limita ai file effettivamente presenti, quindi resta eseguibile anche su
|
||||||
|
un archivio diverso.
|
||||||
|
|
||||||
|
python3 -m unittest test_regression -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from creoparams.extract import extract, latest_versions, split_filename
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
#: Parametri che ci aspettiamo di leggere da ogni .prt aziendale.
|
||||||
|
REQUIRED = ["CODICE", "DENOMINAZIONE", "REVISIONE", "STAMPIGLIATURA",
|
||||||
|
"MATERIALE", "DISEGNATORE", "DATA"]
|
||||||
|
|
||||||
|
#: Valori verificati manualmente. Vanno estesi man mano che si confrontano
|
||||||
|
#: altri file con Creo: e' questa la rete di sicurezza vera.
|
||||||
|
KNOWN = {
|
||||||
|
"9258400201.prt": {
|
||||||
|
"CODICE": "9258400201",
|
||||||
|
"DENOMINAZIONE": "Carter lato destro",
|
||||||
|
"MATERIALE": "ACC.INOX LAMIERA SP.4",
|
||||||
|
"DISEGNATORE": "Grilli",
|
||||||
|
"DATA": "9/02/2026",
|
||||||
|
},
|
||||||
|
"9258400205.prt": {
|
||||||
|
"CODICE": "9258400205",
|
||||||
|
"DENOMINAZIONE": "Tirante carter lateriali",
|
||||||
|
"MATERIALE": "AISI 304 TONDO D.12",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parts():
|
||||||
|
paths = latest_versions(glob.glob(os.path.join(HERE, "*.prt*")))
|
||||||
|
return [p for p in paths if split_filename(os.path.basename(p))[1] == "prt"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEstrazione(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.parts = _parts()
|
||||||
|
if not cls.parts:
|
||||||
|
raise unittest.SkipTest("nessun file .prt nella cartella")
|
||||||
|
cls.records = {
|
||||||
|
os.path.basename(p): extract(p) for p in cls.parts
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_header_riconosciuto(self):
|
||||||
|
for name, record in self.records.items():
|
||||||
|
with self.subTest(file=name):
|
||||||
|
self.assertEqual(record["model"]["kind"], "PART")
|
||||||
|
self.assertTrue(record["model"]["creo_version"])
|
||||||
|
self.assertGreater(record["extraction"]["sections"], 0)
|
||||||
|
|
||||||
|
def test_parametri_obbligatori_presenti(self):
|
||||||
|
for name, record in self.records.items():
|
||||||
|
for param in REQUIRED:
|
||||||
|
with self.subTest(file=name, param=param):
|
||||||
|
entry = record["parameters"].get(param)
|
||||||
|
self.assertIsNotNone(entry, f"{param} non trovato")
|
||||||
|
self.assertEqual(entry["status"], "found")
|
||||||
|
|
||||||
|
def test_confidenza_alta(self):
|
||||||
|
"""Ogni parametro obbligatorio deve essere confermato in due
|
||||||
|
rappresentazioni indipendenti del file."""
|
||||||
|
for name, record in self.records.items():
|
||||||
|
for param in REQUIRED:
|
||||||
|
with self.subTest(file=name, param=param):
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
record["parameters"][param]["confidence"], 0.95
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valori_noti(self):
|
||||||
|
for stem, expected in KNOWN.items():
|
||||||
|
matching = [
|
||||||
|
r for r in self.records.values()
|
||||||
|
if r["model"]["file_name"] == stem
|
||||||
|
]
|
||||||
|
if not matching:
|
||||||
|
continue
|
||||||
|
record = matching[0]
|
||||||
|
for param, value in expected.items():
|
||||||
|
with self.subTest(file=stem, param=param):
|
||||||
|
self.assertEqual(record["parameters"][param]["value"], value)
|
||||||
|
|
||||||
|
def test_codice_coerente_col_nome_file(self):
|
||||||
|
for name, record in self.records.items():
|
||||||
|
with self.subTest(file=name):
|
||||||
|
self.assertEqual(
|
||||||
|
record["parameters"]["CODICE"]["value"],
|
||||||
|
record["model"]["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parametri_feature_separati(self):
|
||||||
|
"""I parametri dei fori non devono finire tra quelli del modello."""
|
||||||
|
for name, record in self.records.items():
|
||||||
|
for param, entry in record["parameters"].items():
|
||||||
|
if param in ("SCREW_SIZE", "DRILL_DIAMETER", "CSINK_ANGLE"):
|
||||||
|
with self.subTest(file=name, param=param):
|
||||||
|
self.assertEqual(entry["owner"], "feature")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user