Commit 1aaef726 authored by Eric Duminil's avatar Eric Duminil
Browse files

Merge branch 'experimental/install'

parents cbe708e5 11a5065f
......@@ -49,6 +49,7 @@ src/simstadt/
runner.py # core workflow execution, SimStadt discovery
workflows.py # high-level helpers (heatdemand_simulation, etc.)
utils.py # random_id, clean_old_workflows, etc.
install.py # simstadt --install: download and extract SimStadt to ~/Desktop
templates/ # bundled workflow templates shipped with the package
results/
__init__.py # factory function, re-exports
......
......@@ -7,7 +7,8 @@ SimStadt is a city simulation tool for energy and urban analysis developed at HF
## Requirements
- Python 3.10+
- [SimStadt](https://simstadt.hft-stuttgart.de/download/InstallFiles/SimStadt2_latest.zip) installed separately
- Java 17+
- [SimStadt](https://simstadt.hft-stuttgart.de/download/InstallFiles/SimStadt2_latest.zip) installed separately (or use `simstadt --install`)
## Installation
......@@ -60,6 +61,9 @@ If no project path is specified, workflows are run in a temporary repository und
# Print detected SimStadt installation path and version
simstadt
# Download and install the latest SimStadt release to ~/Desktop
simstadt --install
# Launch the SimStadt GUI
simstadt --gui
......
......@@ -6,10 +6,10 @@ import re
import subprocess
import sys
from pathlib import Path
from .install import cmd_install
from .utils import chdir
def cmd_info(args: argparse.Namespace) -> None:
from .runner import get_simstadt_folder, get_simstadt_output, _simstadt_script
......@@ -82,6 +82,7 @@ def build_parser() -> argparse.ArgumentParser:
description="simstadt - Python library for SimStadt workflows.",
)
parser.add_argument("--gui", action="store_true", help="Launch the SimStadt GUI.")
parser.add_argument("--install", action="store_true", help="Download and install the latest SimStadt release to ~/Desktop.")
parser.add_argument("template", nargs="?", help="Template name (from SIMSTADT_TEMPLATE_PATH) or path to a .flow directory.")
parser.add_argument("citygml", nargs="?", type=Path, help="Path to the CityGML input file.")
parser.add_argument("-d", "--description", help="Human-readable label for the result.")
......@@ -96,6 +97,10 @@ def build_parser() -> argparse.ArgumentParser:
def app() -> None:
parser = build_parser()
args = parser.parse_args()
if args.install:
cmd_install()
if args.template and args.citygml:
cmd_run(args)
else:
......
"""Download and install SimStadt from the official release ZIP."""
import io
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from .runner import SIMSTADT_VERSION_FORMAT
def _parse_simstadt_name(manifest: str) -> str:
m = re.search(f"Implementation-Version: *{SIMSTADT_VERSION_FORMAT}", manifest)
if not m:
raise ValueError("Cannot parse Implementation-Version from MANIFEST.MF")
version, branch, rev, date = m.groups()
return f"SimStadt2_{version}_{branch}_{date}_{rev}"
def _get_java_version() -> int | None:
try:
result = subprocess.run(["java", "-version"], capture_output=True, text=True)
m = re.search(r'version "(\d+)', result.stderr)
return int(m.group(1)) if m else None
except FileNotFoundError:
return None
def cmd_install() -> None:
url = "https://simstadt.hft-stuttgart.de/download/InstallFiles/SimStadt2_latest.zip"
desktop = Path.home() / "Desktop"
if not desktop.exists():
print(f"Error: {desktop} not found", file=sys.stderr)
raise SystemExit(1)
print("Downloading SimStadt...", file=sys.stderr)
tmp = None
try:
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
tmp = Path(f.name)
try:
urllib.request.urlretrieve(url, tmp)
except Exception as e:
print(f"Error: Download failed: {e}", file=sys.stderr)
raise SystemExit(1)
try:
with zipfile.ZipFile(tmp) as zf:
jar_entry = next(
(
n
for n in zf.namelist()
if re.match(r"lib/simstadt-desktop-.*\.jar$", n)
),
None,
)
if not jar_entry:
print(
"Error: simstadt-desktop JAR not found in ZIP", file=sys.stderr
)
raise SystemExit(1)
with zipfile.ZipFile(io.BytesIO(zf.read(jar_entry))) as jar:
if "META-INF/MANIFEST.MF" not in jar.namelist():
print("Error: MANIFEST.MF not found in JAR", file=sys.stderr)
raise SystemExit(1)
manifest = jar.read("META-INF/MANIFEST.MF").decode(errors="replace")
try:
name = _parse_simstadt_name(manifest)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
raise SystemExit(1)
dest = desktop / name
if dest.exists():
print(f"Already installed: {name}")
return
dest.mkdir()
try:
for member in zf.infolist():
member_path = (dest / member.filename).resolve()
if not member_path.is_relative_to(dest.resolve()):
print(
f"Error: ZIP contains unsafe path: {member.filename}",
file=sys.stderr,
)
raise SystemExit(1)
zf.extractall(dest)
for member in zf.infolist():
mode = member.external_attr >> 16
if mode:
(dest / member.filename).chmod(mode)
except BaseException:
shutil.rmtree(dest, ignore_errors=True)
raise
print(f"Installed: {dest}")
except zipfile.BadZipFile as e:
print(f"Error: Invalid ZIP file: {e}", file=sys.stderr)
raise SystemExit(1)
finally:
if tmp and tmp.exists():
tmp.unlink()
java_version = _get_java_version()
if java_version is None:
print(
"Warning: Java not found. SimStadt requires Java 17. Download: https://bell-sw.com/pages/downloads/#jdk-17-lts",
file=sys.stderr,
)
elif java_version < 17:
print(
f"Warning: Java {java_version} found but SimStadt requires Java 17. Download: https://bell-sw.com/pages/downloads/#jdk-17-lts",
file=sys.stderr,
)
......@@ -26,6 +26,7 @@ from .utils import chdir, random_id
PARAMS = "params.xml"
SIMSTADT2_GLOB = "SimStadt2_0.*/"
SIMSTADT_TEMP_REPO = Path(tempfile.gettempdir()) / "simstadt_repo"
SIMSTADT_VERSION_FORMAT = r"(\S+)\s*\((\w+), rev. (\w+), (\d+)\s*\)"
load_dotenv()
......@@ -69,9 +70,9 @@ def get_template_path() -> Path:
)
def get_version_and_date() -> tuple[str, str]:
SIMSTADT_VERSION_FORMAT = re.compile(r"Launching SimStadt (\S+) \((\w+), rev. (\w+), (\d+)\)")
SIMSTADT_VERSION_PATTERN = re.compile(f"Launching SimStadt {SIMSTADT_VERSION_FORMAT}")
output = get_simstadt_output(get_simstadt_folder())
m = SIMSTADT_VERSION_FORMAT.search(output)
m = SIMSTADT_VERSION_PATTERN.search(output)
if not m:
raise ValueError(f"No SimStadt version string found in output:\n{output}")
version, _branch, _commit, date = m.groups()
......
"""Tests for the simstadt --install command."""
from unittest.mock import MagicMock, patch
import pytest
from simstadt.install import _get_java_version, _parse_simstadt_name
########################
# Get SimStadt name #
########################
def test_parse_simstadt_name():
manifest = (
"Implementation-Version: 0.14.0-SNAPSHOT (develop, rev. 4431e10, 20260520\n )\n"
)
assert (
_parse_simstadt_name(manifest)
== "SimStadt2_0.14.0-SNAPSHOT_develop_20260520_4431e10"
)
def test_parse_simstadt_name_invalid():
with pytest.raises(ValueError, match="Cannot parse"):
_parse_simstadt_name("Manifest-Version: 1.0\nCreated-By: Maven\n")
########################
# Test Java Version #
########################
def test_get_java_version_17():
with patch("simstadt.install.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.3" 2023-04-18\n'
)
assert _get_java_version() == 17
def test_get_java_version_old():
with patch("simstadt.install.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stderr='java version "11.0.2" 2019-01-15\n')
assert _get_java_version() == 11
def test_get_java_version_not_found():
with patch("simstadt.install.subprocess.run", side_effect=FileNotFoundError):
assert _get_java_version() is None
def test_get_java_version_unrecognised_output():
with patch("simstadt.install.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stderr="GraalVM CE 21 (2024-01-16)\n")
assert _get_java_version() is None
import sys
import pytest
from simstadt import main
def test_simstadt_script(simstadt_folder, capsys):
@pytest.mark.integration
def test_simstadt_script(simstadt_folder, monkeypatch, capsys):
monkeypatch.setattr(sys, "argv", ["simstadt"])
main()
captured = capsys.readouterr()
assert "SimStadt folder :" in captured.out
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment