# Copyright (c) 2026 Kjell Tore Guttormsen. MIT License, see LICENSE. """Én inngang til modellen bak notatet «Den dynamiske rettsstaten». Bruk: python3 kjor.py [--data ] [--modell ] [...] Bare standardbiblioteket, Python 3.9 eller nyere. Kommandoene står i HJELP under. Exit-koder: 0 ok, 1 forskjeller funnet av kontroll, 2 feil i inndata eller bruk, 3 miljøet mangler (numpy eller scipy). Valg av Python for kommandoene som trenger numpy og scipy: (1) denne tolken hvis den har numpy 2.0 eller nyere (numpy.trapezoid) og scipy; (2) ellers Python i den egne mappa /venv, som kjører samme kommando på nytt; (3) ellers exit-kode 3 med en melding om hva som mangler. Ingenting installeres uten «miljo --opprett». Grensesnitt for modulene i modell-mappa (scenario.py, kontroll.py, arbeidskopi.py): hver har ``main(argv: list[str]) -> int``, der argv er argumentene etter kommandonavnet. Før main kalles, ligger modell-mappa og modell-mappa/kjerne først i sys.path, slik at ``import sim`` gir kjernen i samme modell-mappe. ``sys.exit("melding")`` gir exit-kode 2 og meldingen uendret; ``sys.exit(n)`` gir exit-kode n. """ from __future__ import annotations import importlib import importlib.util import json import os import platform import shlex import subprocess import sys import traceback from pathlib import Path from typing import TYPE_CHECKING, NamedTuple, TypedDict if TYPE_CHECKING: from collections.abc import Callable from types import ModuleType HER = Path(__file__).resolve().parent VENV_MERKE = "DYNAMISK_RETTSSTAT_VENV" # satt når kjor.py er startet på nytt med Python fra den egne mappa KJERNEMODULER = ("sim", "diagnose", "forlop") MODELLMODULER = ("scenario", "kontroll", "arbeidskopi") OK, FORSKJELLER, FEIL_BRUK, MILJO_MANGLER = 0, 1, 2, 3 HJELP = """\ Bruk: python3 kjor.py [--data ] [--modell ] [...] Kommandoer: diagnose finn leddet som binder, fra egne tall (trenger bare Python) forlop ["ledd"] stabilitetssjekk og forløp måned for måned (numpy og scipy) scenario [--ut ] kjør en scenariofil (numpy og scipy) notatet [--ut ] kjør notatets simuleringer uendret, om lag 1-2 min (numpy og scipy) kontroll [--rask] kontroller at modellen regner som notatet miljo [--opprett] vis Python, numpy og scipy; --opprett installerer dem i en egen mappe arbeidskopi lag en kopi av modellen som du kan endre Valg før kommandoen: --data mappa der den egne Python-mappa (venv) med numpy og scipy ligger (standard ~/.dynamisk-rettsstat) --modell bruk modellen i en arbeidskopi i stedet for pluginens Exit-koder: 0 ok, 1 kontroll fant forskjeller, 2 feil i inndata eller bruk, 3 numpy eller scipy mangler. """ TREGER_REGNEMILJO = ("forlop", "scenario", "notatet") KOMMANDOER = ("diagnose", "forlop", "scenario", "notatet", "kontroll", "miljo", "arbeidskopi") class Bruksfeil(Exception): """Feil i kommandolinjen. Meldingen er til brukeren og gir exit-kode 2.""" class Valg(NamedTuple): """Kommandolinjen, tolket.""" argv: list[str] # hele kommandolinjen, slik den kom inn data: Path # mappa der den egne venv ligger data_oppgitt: bool # om --data ble oppgitt med en brukbar verdi modell: Path # modell-mappa som brukes (pluginens eller en arbeidskopi) kommando: str args: list[str] # argumentene etter kommandoen class Beskrivelse(TypedDict): """Hva en Python-tolk har av numpy og scipy.""" python: str sti: str numpy: str | None trapezoid: bool scipy: str | None class Tolk(NamedTuple): """Hvilken Python som skal regne: «denne», «venv» eller «mangler».""" hvor: str python: Path | None mangler: list[str] # ---------------- kommandolinjen ---------------- def standard_datamappe() -> Path: """Mappa som brukes når --data ikke er oppgitt.""" return Path.home() / ".dynamisk-rettsstat" def datamappe(verdi: str | None) -> tuple[Path, bool]: """Tom verdi, eller en verdi med «${» (ikke satt inn av Claude), betyr «ikke oppgitt».""" if verdi is None or not verdi.strip() or "${" in verdi: return standard_datamappe(), False return Path(verdi).expanduser().resolve(), True def modellmappe(verdi: str | None) -> Path: """Modell-mappa: pluginens egen, eller en arbeidskopi (mappa selv eller mappa over modell/).""" if verdi is None: return HER if not verdi.strip(): melding = "--modell må ha en mappe etter seg." raise Bruksfeil(melding) sti = Path(verdi).expanduser().resolve() for kandidat in (sti, sti / "modell"): if (kandidat / "kjerne").is_dir(): return kandidat melding = f"Fant ingen modell i {sti}: mappa må ha undermappa kjerne/ eller modell/kjerne/." raise Bruksfeil(melding) def tolk_argumenter(argv: list[str]) -> Valg: """Tolk de globale valgene og finn kommandoen.""" verdier: dict[str, str | None] = {"--data": None, "--modell": None} i = 0 while i < len(argv) and argv[i].startswith("-"): arg = argv[i] if arg in {"-h", "--hjelp", "--help"}: return Valg(argv=argv, data=standard_datamappe(), data_oppgitt=False, modell=HER, kommando="hjelp", args=[]) navn, likhetstegn, verdi = arg.partition("=") if navn not in verdier: melding = f"Ukjent valg {arg}. Gyldige valg før kommandoen: --data , --modell ." raise Bruksfeil(melding) if likhetstegn: i += 1 elif i + 1 < len(argv): verdi = argv[i + 1] i += 2 else: melding = f"{navn} må ha en mappe etter seg." raise Bruksfeil(melding) verdier[navn] = verdi if i >= len(argv): melding = "Mangler kommando." raise Bruksfeil(melding) kommando, args = argv[i], argv[i + 1:] if kommando not in KOMMANDOER and kommando != "_beskriv": melding = f"Ukjent kommando «{kommando}». Gyldige kommandoer: {', '.join(KOMMANDOER)}." raise Bruksfeil(melding) data, oppgitt = datamappe(verdier["--data"]) return Valg(argv, data, oppgitt, modellmappe(verdier["--modell"]), kommando, args) # ---------------- Python-miljøet ---------------- def beskriv_denne() -> Beskrivelse: """Python, numpy og scipy i tolken som kjører nå.""" ut = Beskrivelse(python=platform.python_version(), sti=sys.executable, numpy=None, trapezoid=False, scipy=None) try: numpy = importlib.import_module("numpy") except ImportError: pass else: ut["numpy"] = str(getattr(numpy, "__version__", "ukjent versjon")) ut["trapezoid"] = hasattr(numpy, "trapezoid") try: importlib.import_module("scipy.optimize") scipy = importlib.import_module("scipy") except ImportError: pass else: ut["scipy"] = str(getattr(scipy, "__version__", "ukjent versjon")) return ut def mangler(beskrivelse: Beskrivelse) -> list[str]: """Det som mangler for å regne med numpy og scipy, i ord.""" ut = [] if beskrivelse["numpy"] is None: ut.append("numpy") elif not beskrivelse["trapezoid"]: ut.append(f"numpy 2.0 eller nyere (har {beskrivelse['numpy']})") if beskrivelse["scipy"] is None: ut.append("scipy") return ut def beskriv_tolk(python: Path) -> Beskrivelse | None: """Beskriv en annen Python-tolk ved å la den kjøre denne fila. None hvis det ikke går.""" try: resultat = subprocess.run( [str(python), str(Path(__file__).resolve()), "_beskriv"], capture_output=True, text=True, encoding="utf-8", check=False, ) except OSError: return None if resultat.returncode != 0: return None try: data = json.loads(resultat.stdout.strip().splitlines()[-1]) except (ValueError, IndexError): return None return Beskrivelse( python=str(data["python"]), sti=str(data["sti"]), numpy=data["numpy"], trapezoid=bool(data["trapezoid"]), scipy=data["scipy"], ) def venv_mappe(data: Path) -> Path: """Den egne Python-mappa med numpy og scipy.""" return data / "venv" def venv_python(data: Path) -> Path: """Python-tolken i den egne mappa.""" if os.name == "nt": return venv_mappe(data) / "Scripts" / "python.exe" return venv_mappe(data) / "bin" / "python" def velg_tolk(data: Path) -> Tolk: """Velg Python for kommandoene som trenger numpy og scipy.""" mangel = mangler(beskriv_denne()) if not mangel: return Tolk("denne", Path(sys.executable), []) kandidat = venv_python(data) if kandidat.exists() and os.environ.get(VENV_MERKE) != "1": return Tolk("venv", kandidat, mangel) return Tolk("mangler", None, mangel) def kjor_paa_nytt(python: Path, argv: list[str]) -> int: """Kjør samme kommando med en annen Python og gi videre exit-koden.""" sys.stdout.flush() miljo = dict(os.environ) miljo[VENV_MERKE] = "1" return subprocess.run([str(python), str(Path(__file__).resolve()), *argv], env=miljo, check=False).returncode def kommando_tekst(valg: Valg, *args: str) -> str: """Kommandolinjen brukeren kan skrive for å kjøre kjor.py med samme --data.""" deler = ["python3", str(Path(__file__).resolve())] if valg.data_oppgitt: deler += ["--data", str(valg.data)] return " ".join(shlex.quote(d) for d in [*deler, *args]) def melding_mangler(valg: Valg, tolk: Tolk) -> str: """Norsk melding for exit-kode 3.""" linjer = [ f"«{valg.kommando}» trenger numpy (2.0 eller nyere) og scipy. Mangler: {', '.join(tolk.mangler)}.", f"Python som ble brukt: {sys.executable} ({platform.python_version()}).", ] if os.environ.get(VENV_MERKE) == "1": linjer.append(f"Den egne mappa {venv_mappe(valg.data)} mangler dem også. Kommandoen under installerer " "dem der på nytt fra PyPI (krever internett):") else: linjer.append(f"Fant ingen egen mappe med numpy og scipy i {venv_mappe(valg.data)}. Kommandoen under " "lager den og installerer numpy og scipy fra PyPI der (krever internett):") linjer.append(f" {kommando_tekst(valg, 'miljo', '--opprett')}") linjer.append("Ingenting installeres uten at du ber om det. diagnose virker uten numpy og scipy.") return "\n".join(linjer) # ---------------- å kjøre kjernen og modulene ---------------- def forbered_stier(modell: Path) -> None: """Legg modell-mappa og kjernen først i sys.path, og glem moduler lastet fra en annen mappe.""" sys.dont_write_bytecode = True # ingen __pycache__ i plugin-mappa for navn in (*KJERNEMODULER, *MODELLMODULER): sys.modules.pop(navn, None) fjern = {str(modell), str(modell / "kjerne")} if modell != HER: fjern.add(str(HER)) sys.path[:] = [sti for sti in sys.path if str(Path(sti or ".").resolve()) not in fjern] sys.path[:0] = [str(modell / "kjerne"), str(modell)] def exit_kode(kode: object) -> int: """Exit-koden for et sys.exit(kode) fra kjernen eller en modul.""" if kode is None: return OK if isinstance(kode, int): return kode sys.stdout.flush() print(kode, file=sys.stderr) return FEIL_BRUK def fang(funksjon: Callable[[], object], hva: str) -> int: """Kjør funksjonen og gjør sys.exit og uventede feil om til exit-koder.""" try: resultat = funksjon() except SystemExit as stopp: return exit_kode(stopp.code) except Exception: # noqa: BLE001 - alt som ikke er SystemExit, er en feil i koden, ikke i inndata sys.stdout.flush() traceback.print_exc() print(f"Uventet feil i {hva} (se over).", file=sys.stderr) return FEIL_BRUK if isinstance(resultat, int) and not isinstance(resultat, bool): return resultat return OK def last_kjerne(modell: Path, navn: str) -> ModuleType: """Last en kjernemodul fra modell-mappa/kjerne.""" forbered_stier(modell) return importlib.import_module(navn) def last_modul(modell: Path, navn: str) -> ModuleType | None: """Last modell-mappa/.py. None hvis fila ikke finnes.""" fil = modell / f"{navn}.py" if not fil.is_file(): return None forbered_stier(modell) spec = importlib.util.spec_from_file_location(navn, fil) if spec is None or spec.loader is None: return None modul = importlib.util.module_from_spec(spec) sys.modules[navn] = modul spec.loader.exec_module(modul) return modul def kjor_modul(valg: Valg, navn: str) -> int: """Kall main(argv) i modell-mappa/.py med argumentene etter kommandoen.""" fil = valg.modell / f"{navn}.py" if not fil.is_file(): print(f"Kommandoen «{valg.kommando}» er ikke på plass i denne modellen ennå: fant ikke {fil}.", file=sys.stderr) return FEIL_BRUK def start() -> object: main = getattr(last_modul(valg.modell, navn), "main", None) if not callable(main): sys.exit(f"{fil} mangler funksjonen main(argv).") return main(list(valg.args)) return fang(start, f"{navn}.py") # ---------------- kommandoene ---------------- def kommando_diagnose(valg: Valg) -> int: """Kjernens diagnose.main.""" if len(valg.args) != 1: melding = "Bruk: kjor.py diagnose " raise Bruksfeil(melding) diagnose = last_kjerne(valg.modell, "diagnose") return fang(lambda: diagnose.main(valg.args[0]), "diagnose.py") def kommando_forlop(valg: Valg) -> int: """Kjernens forlop.main.""" if len(valg.args) not in {1, 2}: melding = 'Bruk: kjor.py forlop ["navn på ledd"]' raise Bruksfeil(melding) forlop = last_kjerne(valg.modell, "forlop") return fang(lambda: forlop.main(*valg.args), "forlop.py") def kommando_notatet(valg: Valg) -> int: """Kjør kjernens sim.py uendret, med ut-mappa som arbeidsmappe (sim.py skriver data/ der).""" ut = Path.cwd() / "resultater" / "notatet" args = list(valg.args) if args: if len(args) != 2 or args[0] != "--ut": # noqa: PLR2004 - «--ut » melding = "Bruk: kjor.py notatet [--ut ]" raise Bruksfeil(melding) ut = Path(args[1]).expanduser() ut = ut.resolve() if ut == valg.modell or valg.modell in ut.parents: melding = f"Ut-mappa {ut} ligger inne i modell-mappa {valg.modell}. Velg en annen med --ut ." raise Bruksfeil(melding) if ut.exists() and not ut.is_dir(): melding = f"{ut} finnes og er ikke en mappe. Velg en annen med --ut ." raise Bruksfeil(melding) ut.mkdir(parents=True, exist_ok=True) print(f"Kjører notatets simuleringer (kjerne/sim.py, uendret) i {ut}. Det tar om lag 1-2 minutter.\n", flush=True) miljo = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1", PYTHONUNBUFFERED="1") utskrift = ut / "sim_utskrift.txt" with utskrift.open("w", encoding="utf-8") as logg, subprocess.Popen( [sys.executable, str(valg.modell / "kjerne" / "sim.py")], cwd=str(ut), stdout=subprocess.PIPE, text=True, encoding="utf-8", env=miljo, ) as prosess: if prosess.stdout is not None: for linje in prosess.stdout: sys.stdout.write(linje) logg.write(linje) if prosess.returncode != 0: print(f"sim.py stoppet med exit-kode {prosess.returncode}.", file=sys.stderr) return FEIL_BRUK antall = len(list((ut / "data").glob("*.dat"))) print(f"\nFerdig: {antall} datafiler i {ut / 'data'}, utskriften i {utskrift}.") print("«kontroll» sammenligner pluginens egen kjøring med notatet.") return OK def laaste_versjoner(modell: Path) -> dict[str, str]: """Notatets låste versjoner fra modell/requirements.txt.""" ut = {} fil = modell / "requirements.txt" if fil.is_file(): for linje in fil.read_text(encoding="utf-8").splitlines(): navn, skille, versjon = linje.split("#")[0].strip().partition("==") if skille: ut[navn.strip().lower()] = versjon.strip() return ut def vis_tolk(beskrivelse: Beskrivelse, laast: dict[str, str]) -> list[str]: """Linjer som beskriver Python, numpy og scipy i en tolk.""" linjer = [f" Python {beskrivelse['python']} ({beskrivelse['sti']})"] for navn in ("numpy", "scipy"): versjon = beskrivelse["numpy"] if navn == "numpy" else beskrivelse["scipy"] notatet = f" (notatet: {laast[navn]})" if navn in laast else "" linjer.append(f" {navn}: {versjon or 'mangler'}{notatet}") return linjer def samme_som_notatet(beskrivelse: Beskrivelse, laast: dict[str, str]) -> bool: """Sjekk om tolken har notatets låste versjoner av numpy og scipy.""" return beskrivelse["numpy"] == laast.get("numpy") and beskrivelse["scipy"] == laast.get("scipy") def rapport_miljo(valg: Valg) -> None: """Skriv ut hva denne Python og den egne mappa har.""" laast = laaste_versjoner(valg.modell) denne = beskriv_denne() print("Python som kjører nå:") print("\n".join(vis_tolk(denne, laast))) vpy = venv_python(valg.data) print(f"\nEgen mappe for numpy og scipy: {venv_mappe(valg.data)}") venv: Beskrivelse | None = None if not vpy.exists(): print(" finnes ikke. «miljo --opprett» lager den og installerer numpy og scipy fra PyPI " "(krever internett).") else: venv = beskriv_tolk(vpy) if venv is None: print(f" finnes, men {vpy} lot seg ikke starte. «miljo --opprett» prøver å lage den på nytt.") else: print("\n".join(vis_tolk(venv, laast))) print() regner = None if not mangler(denne): regner = denne print("forlop, scenario, notatet og kontroll regner med Python som kjører nå.") elif venv is not None and not mangler(venv): regner = venv print("forlop, scenario, notatet og kontroll regner med Python i den egne mappa.") else: hva = ", ".join(mangler(venv if venv is not None else denne)) print(f"forlop, scenario, notatet og kontroll kan ikke regne nå: {hva} mangler.") if regner is not None: if samme_som_notatet(regner, laast): print("Versjonene av numpy og scipy er de samme som notatets, så kontroll kan gi bit for bit.") else: print("Versjonene av numpy og scipy er ikke de samme som notatets: kontroll sammenligner da " "med toleranse, ikke bit for bit.") def kjor_prosess(kommando: list[str]) -> int: """Kjør et program (pip, venv) med utskriften rett til skjermen. Exit-koden tilbake.""" sys.stdout.flush() try: return subprocess.run(kommando, check=False).returncode except OSError as feil: print(f"Kunne ikke starte {kommando[0]}: {feil}", file=sys.stderr) return 1 def opprett_miljo(valg: Valg) -> int: """Lag den egne mappa og installer numpy og scipy: låste versjoner først, ellers nyeste.""" mappe = venv_mappe(valg.data) vpy = venv_python(valg.data) if not vpy.exists(): print(f"Lager egen Python-mappa {mappe} med {sys.executable} ({platform.python_version()}).") valg.data.mkdir(parents=True, exist_ok=True) if kjor_prosess([sys.executable, "-m", "venv", str(mappe)]) != 0 or not vpy.exists(): print(f"Kunne ikke lage {mappe}. På noen Linux-systemer må pakken python3-venv installeres først.", file=sys.stderr) return MILJO_MANGLER pip = [str(vpy), "-m", "pip", "install", "--disable-pip-version-check"] krav = valg.modell / "requirements.txt" laast = laaste_versjoner(valg.modell) print(f"\nInstallerer notatets versjoner ({', '.join(f'{n} {v}' for n, v in laast.items())}) fra PyPI.") toleranse = False if kjor_prosess([*pip, "-r", str(krav)]) != 0: print("\nNotatets låste versjoner lot seg ikke installere for denne Python-versjonen. " "Prøver numpy 2.0 eller nyere og scipy.") toleranse = True if kjor_prosess([*pip, "numpy>=2.0", "scipy"]) != 0: print(f"\nInstallasjonen feilet. Se meldingene over. Mappa {mappe} kan slettes.", file=sys.stderr) return MILJO_MANGLER print() rapport_miljo(valg) if toleranse: print("\nNumpy og scipy er ikke notatets låste versjoner. Resultatene kontrolleres da med toleranse, " "ikke bit for bit.") return OK def kommando_miljo(valg: Valg) -> int: """Vis miljøet, eller lag den egne mappa med --opprett.""" if valg.args == ["--opprett"]: return opprett_miljo(valg) if valg.args: melding = "Bruk: kjor.py miljo [--opprett]" raise Bruksfeil(melding) rapport_miljo(valg) return OK def kommando_beskriv(_valg: Valg) -> int: """Intern kommando for beskriv_tolk(): skriv beskrivelsen av denne tolken som JSON.""" print(json.dumps(beskriv_denne())) return OK def kommando_hjelp(_valg: Valg) -> int: """Skriv bruken.""" print(HJELP, end="") return OK def kommando_regner(valg: Valg) -> int: """Kommandoene som trenger numpy og scipy: velg Python først (se toppen av fila).""" if valg.kommando == "scenario" and not (valg.modell / "scenario.py").is_file(): return kjor_modul(valg, "scenario") # sier at kommandoen ikke er på plass ennå if valg.kommando == "forlop" and valg.args and not Path(valg.args[0]).exists(): print(f"Fant ikke fila {valg.args[0]}.", file=sys.stderr) return FEIL_BRUK tolk = velg_tolk(valg.data) if tolk.hvor == "venv" and tolk.python is not None: return kjor_paa_nytt(tolk.python, valg.argv) if tolk.hvor == "mangler" and valg.kommando != "kontroll": # kontroll gjør det den kan uten print(melding_mangler(valg, tolk), file=sys.stderr) return MILJO_MANGLER handlinger: dict[str, Callable[[Valg], int]] = { "forlop": kommando_forlop, "notatet": kommando_notatet, "scenario": lambda v: kjor_modul(v, "scenario"), "kontroll": lambda v: kjor_modul(v, "kontroll"), } return handlinger[valg.kommando](valg) def utfor(valg: Valg) -> int: """Utfør kommandoen.""" handlinger: dict[str, Callable[[Valg], int]] = { "hjelp": kommando_hjelp, "_beskriv": kommando_beskriv, "diagnose": kommando_diagnose, "miljo": kommando_miljo, "arbeidskopi": lambda v: kjor_modul(v, "arbeidskopi"), } return handlinger.get(valg.kommando, kommando_regner)(valg) def main(argv: list[str]) -> int: """Inngangen. Returnerer exit-koden.""" try: if not argv: print(HJELP, end="", file=sys.stderr) return FEIL_BRUK return utfor(tolk_argumenter(argv)) except Bruksfeil as feil: sys.stdout.flush() print(f"{feil}\nSkriv «python3 kjor.py --hjelp» for bruk.", file=sys.stderr) return FEIL_BRUK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))