# (c) Kevin Dunn, 2010-2026. MIT License.
"""The project file: a source file, its recipe, its model variants and saved selections, in one zip.
========================= ====================================================================
Member Holds
========================= ====================================================================
``manifest.json`` the format and library versions, the recipe, the variants, the
saved selections
``source/<file name>`` the source file's own bytes, exactly as read
``recipe.py`` the recipe as a script, to run next to the source file
``fits/<k>/<array>.npy`` variant ``k``'s fitted scores, loadings, T² and SPE
========================= ====================================================================
Opening a project executes nothing from it: the manifest is JSON, checked field by
field; the source is read as the recipe says (CSV or Excel); the arrays are read with
``numpy.load(allow_pickle=False)``. ``recipe.py`` is written for the reader and never
read back. :meth:`Project.open` refits every variant from the source and the recipe and
compares the result with the stored arrays, so a project that no longer reproduces,
because the library or the file changed, says so instead of being trusted.
"""
from __future__ import annotations
import hashlib
import io
import json
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
from process_improve.multivariate import PCA
from .recipe import Recipe, RowId, _is_row_id, _release
from .variant import Variant
if TYPE_CHECKING:
import pandas as pd
from .variant import Fit
#: What ``manifest.json`` calls this format, and the version of it this module writes.
FORMAT, FORMAT_VERSION = "process-improve-project", 1
#: The largest project this module opens, unpacked, and the most members it reads.
MAX_UNPACKED_BYTES, MAX_MEMBERS = 512 * 2**20, 2_000
#: The arrays stored for each variant, by the name of the member that holds them.
ARRAYS = ("scores", "loadings", "T2", "SPE")
[docs]
@dataclass(frozen=True)
class Check:
"""How one stored array compares with the same array refitted on opening."""
variant: str
array: str
max_difference: float
ok: bool
[docs]
@dataclass(frozen=True)
class Opened:
"""A project opened: its table, every variant fitted, and how each fit compares with the stored one."""
project: Project
table: pd.DataFrame
fits: list[Fit]
checks: list[Check]
library: str
@property
def reproduces(self) -> bool:
"""Whether every refitted array matches the one stored."""
return all(check.ok for check in self.checks)
[docs]
@dataclass(frozen=True)
class Project:
"""A source file's content, its recipe, the model variants over it, and the reader's saved selections.
Parameters
----------
recipe : Recipe
source : bytes
The source file's content. Its SHA-256 digest must match the recipe's, when the
recipe records one.
variants : tuple of Variant
selections : dict
Named sets of row ids, as the page saves them (a lasso kept under a name).
"""
recipe: Recipe
source: bytes
variants: tuple[Variant, ...] = ()
selections: dict[str, list[RowId]] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Refuse a source whose digest is not the recipe's, and selections that are not named lists of row ids."""
expected = self.recipe.source.sha256
if expected is not None and _digest(self.source) != expected:
raise ValueError(f"The source is not the file the recipe was written for ({self.recipe.source.name}).")
for name, rows in self.selections.items():
if not isinstance(name, str) or not isinstance(rows, list) or not all(_is_row_id(r) for r in rows):
raise TypeError(f"A selection is a name and a list of row ids, got {name!r}: {rows!r}.")
[docs]
def table(self) -> pd.DataFrame:
"""Return the recipe's table, read from the source and refined by every step."""
return self.recipe.run(self.source)
[docs]
def to_bytes(self) -> bytes:
"""Return the project as a zip file, each variant fitted so its arrays are stored with it."""
table = self.table()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(self._manifest(), indent=2))
archive.writestr(_source_member(self.recipe), self.source)
archive.writestr("recipe.py", self.recipe.to_python())
for k, variant in enumerate(self.variants):
for name, values in _arrays(variant.fit(table)).items():
member = io.BytesIO()
np.save(member, values, allow_pickle=False)
archive.writestr(f"fits/{k}/{name}.npy", member.getvalue())
return buffer.getvalue()
[docs]
def save(self, path: str | Path) -> None:
"""Write the project to ``path``."""
Path(path).write_bytes(self.to_bytes())
def _manifest(self) -> dict[str, Any]:
return {
"format": FORMAT,
"version": FORMAT_VERSION,
"library": _release(),
"recipe": self.recipe.to_dict(),
"source": _source_member(self.recipe),
"variants": [v.to_dict() for v in self.variants],
"selections": self.selections,
}
[docs]
@classmethod
def from_bytes(cls, content: bytes) -> Project:
"""Read a project from its zip file's content, checking every field; nothing in it is executed."""
return _read(content)[0]
[docs]
@classmethod
def load(cls, path: str | Path) -> Project:
"""Read a project from ``path``."""
return cls.from_bytes(Path(path).read_bytes())
[docs]
@staticmethod
def open(content: bytes, *, rtol: float = 1e-9) -> Opened:
"""Read a project, refit each variant, and compare every refitted array with the stored one.
Parameters
----------
content : bytes
The project file's content.
rtol : float, default 1e-9
The largest difference, relative to the array's largest magnitude, that still
counts as the same.
"""
project, stored, library = _read(content)
table = project.table()
fits = [variant.fit(table) for variant in project.variants]
checks = []
for variant, fit, arrays in zip(project.variants, fits, stored, strict=True):
for name, refitted in _arrays(fit).items():
saved = arrays.get(name)
if saved is None or saved.shape != refitted.shape:
checks.append(Check(variant.name, name, float("inf"), ok=False))
continue
scale = max(float(np.nanmax(np.abs(saved), initial=0.0)), np.finfo(float).tiny)
difference = float(np.nanmax(np.abs(saved - refitted), initial=0.0)) / scale
same_gaps = bool(np.array_equal(np.isnan(saved), np.isnan(refitted)))
checks.append(Check(variant.name, name, difference, ok=same_gaps and difference <= rtol))
return Opened(project=project, table=table, fits=fits, checks=checks, library=library)
def _digest(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _source_member(recipe: Recipe) -> str:
# Only the base name: a recipe from a file may carry a path, and the member name is ours.
name = recipe.source.name.replace("\\", "/").rsplit("/", 1)[-1] or "source"
return f"source/{name}"
def _arrays(fit: Fit) -> dict[str, np.ndarray]:
rows = fit.rows()
a = fit.variant.n_components
return {
"scores": rows.iloc[:, :a].to_numpy(dtype=float),
"loadings": (fit.model.loadings_ if isinstance(fit.model, PCA) else fit.model.x_loadings_).to_numpy(),
"T2": rows["T2"].to_numpy(dtype=float),
"SPE": rows["SPE"].to_numpy(dtype=float),
}
def _read(content: bytes) -> tuple[Project, list[dict[str, np.ndarray]], str]:
"""Return the project, each variant's stored arrays, and the library version that wrote it."""
try:
archive = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise ValueError("This is not a project file: it is not a zip archive.") from None
with archive:
members = archive.infolist()
if len(members) > MAX_MEMBERS or sum(m.file_size for m in members) > MAX_UNPACKED_BYTES:
raise ValueError("This project file is too large to open.")
names = {m.filename for m in members}
if "manifest.json" not in names:
raise ValueError("This is not a project file: it has no manifest.json.")
try:
manifest = json.loads(archive.read("manifest.json"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"The project's manifest.json is not valid JSON: {error}.") from None
if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
raise ValueError("This is not a project file: its manifest names another format.")
if not isinstance(manifest.get("version"), int) or manifest["version"] > FORMAT_VERSION:
raise ValueError("This project file was written by a newer process-improve; update to open it.")
recipe = Recipe.from_dict(manifest.get("recipe", {}))
member = _source_member(recipe)
if member not in names:
raise ValueError(f"The project has no {member}.")
variants_raw = manifest.get("variants", [])
selections = manifest.get("selections", {})
if not isinstance(variants_raw, list) or not isinstance(selections, dict):
raise TypeError("The manifest's 'variants' must be a list and its 'selections' a mapping.")
project = Project(
recipe=recipe,
source=archive.read(member),
variants=tuple(Variant.from_dict(v) for v in variants_raw),
selections=selections,
)
stored = [
{
name: np.load(io.BytesIO(archive.read(f"fits/{k}/{name}.npy")), allow_pickle=False)
for name in ARRAYS
if f"fits/{k}/{name}.npy" in names
}
for k in range(len(project.variants))
]
library = manifest.get("library")
return project, stored, library if isinstance(library, str) else "unknown"