tlaplus-cli 0.3.2__tar.gz → 0.3.4__tar.gz
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.
- {tlaplus_cli-0.3.2/src/tlaplus_cli.egg-info → tlaplus_cli-0.3.4}/PKG-INFO +1 -1
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/pyproject.toml +3 -2
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/config.py +9 -5
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/project.py +1 -5
- tlaplus_cli-0.3.4/src/tlaplus_cli/resources/__init__.py +1 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/run_tlc.py +10 -4
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/tools_manager.py +21 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/version_manager.py +119 -1
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4/src/tlaplus_cli.egg-info}/PKG-INFO +1 -1
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/SOURCES.txt +1 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_build_tlc_module.py +2 -2
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_project.py +4 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_tools_manager.py +194 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_version_manager.py +129 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/LICENSE +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/MANIFEST.in +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/README.md +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/setup.cfg +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/__init__.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/build_tlc_module.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/check_java.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/cli.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/py.typed +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/resources/default_config.yaml +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli/settings.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/dependency_links.txt +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/entry_points.txt +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/requires.txt +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/top_level.txt +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_check_java.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_cli.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_java_module_naming.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_run_tlc.py +0 -0
- {tlaplus_cli-0.3.2 → tlaplus_cli-0.3.4}/tests/test_spec_resolution.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tlaplus-cli"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "TLA+ tools: download TLC, compile custom modules, run model checker"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -45,7 +45,8 @@ where = ["src"]
|
|
|
45
45
|
include = ["tlaplus_cli*"]
|
|
46
46
|
|
|
47
47
|
[tool.setuptools.package-data]
|
|
48
|
-
tlaplus_cli = ["
|
|
48
|
+
tlaplus_cli = ["py.typed"]
|
|
49
|
+
"tlaplus_cli.resources" = ["*.yaml"]
|
|
49
50
|
|
|
50
51
|
[dependency-groups]
|
|
51
52
|
dev = [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import importlib.resources
|
|
2
2
|
from functools import lru_cache
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
@@ -37,9 +37,12 @@ def cache_dir() -> Path:
|
|
|
37
37
|
return Path(platformdirs.user_cache_dir(_APP_NAME))
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
"""
|
|
42
|
-
|
|
40
|
+
def _default_config_content() -> str:
|
|
41
|
+
"""Read the default config shipped with the package."""
|
|
42
|
+
# Safer than __file__, correctly handles zipped wheels and zipapps.
|
|
43
|
+
return (
|
|
44
|
+
importlib.resources.files("tlaplus_cli.resources").joinpath("default_config.yaml").read_text(encoding="utf-8")
|
|
45
|
+
)
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def _ensure_config() -> Path:
|
|
@@ -47,7 +50,8 @@ def _ensure_config() -> Path:
|
|
|
47
50
|
cp = config_path()
|
|
48
51
|
if not cp.exists():
|
|
49
52
|
cp.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
|
|
53
|
+
# Write default config to the newly created path
|
|
54
|
+
cp.write_text(_default_config_content(), encoding="utf-8")
|
|
51
55
|
return cp
|
|
52
56
|
|
|
53
57
|
|
|
@@ -5,11 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
def _is_project_root(directory: Path, modules_dir: Path | str, classes_dir: Path | str, lib_dir: Path | str) -> bool:
|
|
7
7
|
"""Return True if directory contains at least one project structure marker."""
|
|
8
|
-
return (
|
|
9
|
-
(directory / classes_dir).is_dir()
|
|
10
|
-
or (directory / modules_dir).is_dir()
|
|
11
|
-
or (directory / lib_dir).is_dir()
|
|
12
|
-
)
|
|
8
|
+
return (directory / classes_dir).is_dir() or (directory / modules_dir).is_dir() or (directory / lib_dir).is_dir()
|
|
13
9
|
|
|
14
10
|
|
|
15
11
|
def find_project_root(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resources subpackage."""
|
|
@@ -69,8 +69,7 @@ def tlc(
|
|
|
69
69
|
|
|
70
70
|
# Spec resolution logic
|
|
71
71
|
spec_path = Path(spec)
|
|
72
|
-
candidates = [spec_path, spec_path.with_suffix(".tla"),
|
|
73
|
-
spec_path.parent / "spec" / (spec_path.name + ".tla")]
|
|
72
|
+
candidates = [spec_path, spec_path.with_suffix(".tla"), spec_path.parent / "spec" / (spec_path.name + ".tla")]
|
|
74
73
|
|
|
75
74
|
spec_file = next((c for c in candidates if c.is_file()), None)
|
|
76
75
|
if not spec_file:
|
|
@@ -97,8 +96,15 @@ def tlc(
|
|
|
97
96
|
if modules_path.is_dir():
|
|
98
97
|
extra_jvm_opts.append(f"-DTLA-Library={modules_path}")
|
|
99
98
|
|
|
100
|
-
cmd = [
|
|
101
|
-
|
|
99
|
+
cmd = [
|
|
100
|
+
"java",
|
|
101
|
+
*config.java.opts,
|
|
102
|
+
*extra_jvm_opts,
|
|
103
|
+
"-cp",
|
|
104
|
+
os.pathsep.join(classpath_parts),
|
|
105
|
+
config.tlc.java_class,
|
|
106
|
+
spec_file.name,
|
|
107
|
+
]
|
|
102
108
|
|
|
103
109
|
typer.echo(f"Running TLC on {spec_file.name} ...\nCommand: {' '.join(cmd)}")
|
|
104
110
|
result = subprocess.run(cmd, cwd=str(spec_file.parent))
|
|
@@ -12,9 +12,11 @@ from tlaplus_cli.version_manager import (
|
|
|
12
12
|
clear_cache,
|
|
13
13
|
clear_pin,
|
|
14
14
|
download_version,
|
|
15
|
+
download_version_from_url,
|
|
15
16
|
fetch_remote_versions,
|
|
16
17
|
get_pinned_version_dir,
|
|
17
18
|
get_tools_dir,
|
|
19
|
+
is_url,
|
|
18
20
|
list_local_versions,
|
|
19
21
|
read_version_metadata,
|
|
20
22
|
resolve_latest_version,
|
|
@@ -100,6 +102,25 @@ def install(
|
|
|
100
102
|
version: str = typer.Argument(None),
|
|
101
103
|
force: bool = typer.Option(False, "--force", "-f", help="Force re-download even if already installed."),
|
|
102
104
|
) -> None:
|
|
105
|
+
# --- URL install branch ---
|
|
106
|
+
if version and is_url(version):
|
|
107
|
+
try:
|
|
108
|
+
version_dir = download_version_from_url(version)
|
|
109
|
+
typer.echo("Download complete.")
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
typer.echo(f"Error: {e}", err=True)
|
|
112
|
+
raise typer.Exit(1) from e
|
|
113
|
+
except Exception as e:
|
|
114
|
+
typer.echo(f"Error: Failed to download: {e}", err=True)
|
|
115
|
+
raise typer.Exit(1) from e
|
|
116
|
+
|
|
117
|
+
pinned_dir = get_pinned_version_dir()
|
|
118
|
+
if pinned_dir is None:
|
|
119
|
+
typer.echo(f"Auto-pinning newly installed version {version_dir.name}")
|
|
120
|
+
set_pin(version_dir)
|
|
121
|
+
return
|
|
122
|
+
# --- end URL branch ---
|
|
123
|
+
|
|
103
124
|
config = load_config()
|
|
104
125
|
versions, _ = fetch_remote_versions(config.tla.urls.tags, config.tla.urls.releases, config.tla.urls.per_page)
|
|
105
126
|
if not versions:
|
|
@@ -6,8 +6,10 @@ import shutil
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import time
|
|
8
8
|
from dataclasses import asdict, dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
9
10
|
from enum import Enum
|
|
10
11
|
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
11
13
|
|
|
12
14
|
import requests
|
|
13
15
|
import typer
|
|
@@ -22,6 +24,121 @@ if TYPE_CHECKING:
|
|
|
22
24
|
_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)")
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def is_url(text: str) -> bool:
|
|
28
|
+
"""Return True if *text* looks like an HTTP(S) URL."""
|
|
29
|
+
return text.lower().startswith(("http://", "https://"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_version_from_url(url: str) -> str | None:
|
|
33
|
+
"""Scan URL path segments for a semver-like segment (e.g. 'v1.8.0').
|
|
34
|
+
|
|
35
|
+
Returns the first matching segment, or None if none is found.
|
|
36
|
+
"""
|
|
37
|
+
path = urlparse(url).path
|
|
38
|
+
for segment in path.split("/"):
|
|
39
|
+
if _SEMVER_RE.match(segment):
|
|
40
|
+
return segment
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _utc_now_iso() -> str:
|
|
45
|
+
"""Return the current UTC time in ISO 8601 format, e.g. '2026-04-06T12:51:28Z'."""
|
|
46
|
+
now = datetime.now(tz=UTC)
|
|
47
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def download_version_from_url(url: str) -> Path:
|
|
51
|
+
"""Download tla2tools.jar from *url* and store it in a timestamped version directory.
|
|
52
|
+
|
|
53
|
+
The version name is extracted from URL path segments. The tag (directory suffix) is
|
|
54
|
+
the ISO 8601 download timestamp.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: if no semver segment can be found in the URL.
|
|
58
|
+
"""
|
|
59
|
+
version_name = extract_version_from_url(url)
|
|
60
|
+
if version_name is None:
|
|
61
|
+
msg = (
|
|
62
|
+
'could not extract a version name from the URL. '
|
|
63
|
+
'The URL must contain a version segment (e.g. "v1.8.0").'
|
|
64
|
+
)
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
|
|
67
|
+
tag = _utc_now_iso()
|
|
68
|
+
tools_dir = get_tools_dir()
|
|
69
|
+
version_dir = tools_dir / f"{version_name}-{tag}"
|
|
70
|
+
version_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
jar_path = version_dir / "tla2tools.jar"
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
response = requests.get(
|
|
75
|
+
url,
|
|
76
|
+
stream=True,
|
|
77
|
+
timeout=30,
|
|
78
|
+
headers={"User-Agent": "tlaplus-cli"},
|
|
79
|
+
)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
total = int(response.headers.get("content-length", 0))
|
|
82
|
+
|
|
83
|
+
with Progress(
|
|
84
|
+
"[progress.description]{task.description}",
|
|
85
|
+
BarColumn(),
|
|
86
|
+
DownloadColumn(),
|
|
87
|
+
TransferSpeedColumn(),
|
|
88
|
+
) as progress:
|
|
89
|
+
task = progress.add_task(f"Downloading {version_name}...", total=total or None)
|
|
90
|
+
with jar_path.open("wb") as f:
|
|
91
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
92
|
+
f.write(chunk)
|
|
93
|
+
progress.update(task, advance=len(chunk))
|
|
94
|
+
except Exception:
|
|
95
|
+
shutil.rmtree(version_dir, ignore_errors=True)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
write_version_metadata_from_url(version_dir, version_name=version_name, tag=tag, url=url)
|
|
99
|
+
return version_dir
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_version_metadata_from_url(
|
|
103
|
+
version_dir: Path,
|
|
104
|
+
*,
|
|
105
|
+
version_name: str,
|
|
106
|
+
tag: str,
|
|
107
|
+
url: str,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Write meta-tla2tools.json for a URL-sourced install."""
|
|
110
|
+
tlc2_version_string = ""
|
|
111
|
+
try:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["java", "-cp", "tla2tools.jar", "tlc2.TLC", "-version"],
|
|
114
|
+
cwd=version_dir,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
check=False,
|
|
118
|
+
)
|
|
119
|
+
if result.stdout:
|
|
120
|
+
tlc2_version_string = result.stdout.strip().split("\n")[0]
|
|
121
|
+
except Exception as e:
|
|
122
|
+
typer.echo(f"Warning: Failed to extract TLC version string: {e}", err=True)
|
|
123
|
+
|
|
124
|
+
meta_file = version_dir / "meta-tla2tools.json"
|
|
125
|
+
metadata = {
|
|
126
|
+
"tag_name": version_name,
|
|
127
|
+
"sha": "",
|
|
128
|
+
"published_at": "",
|
|
129
|
+
"tlc2_version_string": tlc2_version_string,
|
|
130
|
+
"prerelease": False,
|
|
131
|
+
"download_url": url,
|
|
132
|
+
"tag": tag,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
with meta_file.open("w") as f:
|
|
137
|
+
json.dump(metadata, f, indent=2)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
typer.echo(f"Warning: Failed to write metadata: {e}", err=True)
|
|
140
|
+
|
|
141
|
+
|
|
25
142
|
def _parse_semver(name: str) -> tuple[int, int, int] | None:
|
|
26
143
|
"""Try to extract (major, minor, patch) from a version name like 'v1.8.0'."""
|
|
27
144
|
m = _SEMVER_RE.match(name)
|
|
@@ -173,7 +290,8 @@ def list_local_versions() -> list[LocalVersion]:
|
|
|
173
290
|
result = []
|
|
174
291
|
for d in tools_dir.iterdir():
|
|
175
292
|
if d.is_dir() and not d.is_symlink():
|
|
176
|
-
|
|
293
|
+
# Split on the FIRST hyphen so timestamp suffixes are preserved whole
|
|
294
|
+
parts = d.name.split("-", 1)
|
|
177
295
|
if len(parts) == 2:
|
|
178
296
|
result.append(LocalVersion(name=parts[0], short_sha=parts[1], path=d))
|
|
179
297
|
return result
|
|
@@ -19,6 +19,7 @@ src/tlaplus_cli.egg-info/dependency_links.txt
|
|
|
19
19
|
src/tlaplus_cli.egg-info/entry_points.txt
|
|
20
20
|
src/tlaplus_cli.egg-info/requires.txt
|
|
21
21
|
src/tlaplus_cli.egg-info/top_level.txt
|
|
22
|
+
src/tlaplus_cli/resources/__init__.py
|
|
22
23
|
src/tlaplus_cli/resources/default_config.yaml
|
|
23
24
|
tests/test_build_tlc_module.py
|
|
24
25
|
tests/test_check_java.py
|
|
@@ -100,8 +100,8 @@ def test_build_with_explicit_path(mocker, tmp_path, base_settings):
|
|
|
100
100
|
assert result.exit_code == 0
|
|
101
101
|
args, _ = mock_run.call_args
|
|
102
102
|
cmd = args[0]
|
|
103
|
-
assert str(project_dir / "classes") in cmd
|
|
104
|
-
assert str(modules_dir / "Foo.java") in cmd
|
|
103
|
+
assert str(project_dir / "classes") in cmd # -d target
|
|
104
|
+
assert str(modules_dir / "Foo.java") in cmd # source file
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def test_build_without_path_uses_workspace_root(mocker, tmp_path, base_settings):
|
|
@@ -9,6 +9,7 @@ def test_project_root_at_spec_dir(tmp_path):
|
|
|
9
9
|
root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
|
|
10
10
|
assert root == tmp_path
|
|
11
11
|
|
|
12
|
+
|
|
12
13
|
def test_project_root_at_parent_of_spec_dir(tmp_path):
|
|
13
14
|
"""Root found one level up when spec is inside a spec/ subdirectory."""
|
|
14
15
|
spec_subdir = tmp_path / "spec"
|
|
@@ -19,6 +20,7 @@ def test_project_root_at_parent_of_spec_dir(tmp_path):
|
|
|
19
20
|
root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
|
|
20
21
|
assert root == tmp_path
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
def test_project_root_none_when_no_structure(tmp_path):
|
|
23
25
|
"""Returns None if neither spec dir nor its parent contain project directories."""
|
|
24
26
|
spec = tmp_path / "spec" / "queue.tla"
|
|
@@ -27,6 +29,7 @@ def test_project_root_none_when_no_structure(tmp_path):
|
|
|
27
29
|
root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
|
|
28
30
|
assert root is None
|
|
29
31
|
|
|
32
|
+
|
|
30
33
|
def test_project_root_detected_by_lib(tmp_path):
|
|
31
34
|
"""Root found based on lib/ directory even without classes/."""
|
|
32
35
|
spec = tmp_path / "tests" / "queue_test.tla"
|
|
@@ -36,6 +39,7 @@ def test_project_root_detected_by_lib(tmp_path):
|
|
|
36
39
|
root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
|
|
37
40
|
assert root == tmp_path
|
|
38
41
|
|
|
42
|
+
|
|
39
43
|
def test_project_root_detected_by_modules(tmp_path):
|
|
40
44
|
"""Root found based on modules/ directory alone."""
|
|
41
45
|
spec = tmp_path / "spec" / "queue.tla"
|
|
@@ -7,6 +7,7 @@ import pytest
|
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
9
|
from tlaplus_cli.cli import app
|
|
10
|
+
from tlaplus_cli.version_manager import download_version_from_url
|
|
10
11
|
|
|
11
12
|
runner = CliRunner()
|
|
12
13
|
|
|
@@ -634,3 +635,196 @@ def test_uninstall_all_flag(mock_cache):
|
|
|
634
635
|
assert result.exit_code == 0
|
|
635
636
|
assert not v1.exists()
|
|
636
637
|
assert not v2.exists()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
# Custom URL install — unit tests for download_version_from_url
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
def test_download_version_from_url_creates_dir(mocker, mock_cache):
|
|
645
|
+
"""A valid URL creates the expected version directory."""
|
|
646
|
+
|
|
647
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
648
|
+
fake_ts = "2026-04-06T12:51:28Z"
|
|
649
|
+
|
|
650
|
+
mocker.patch("tlaplus_cli.version_manager._utc_now_iso", return_value=fake_ts)
|
|
651
|
+
mock_response = mocker.MagicMock()
|
|
652
|
+
mock_response.raise_for_status = mocker.MagicMock()
|
|
653
|
+
mock_response.headers = {"content-length": "16"}
|
|
654
|
+
mock_response.iter_content.return_value = [b"fake jar content"]
|
|
655
|
+
mocker.patch("tlaplus_cli.version_manager.requests.get", return_value=mock_response)
|
|
656
|
+
mocker.patch("tlaplus_cli.version_manager.write_version_metadata_from_url")
|
|
657
|
+
|
|
658
|
+
result_dir = download_version_from_url(url)
|
|
659
|
+
tools_dir = mock_cache / "tools"
|
|
660
|
+
assert result_dir == tools_dir / f"v1.9.0-{fake_ts}"
|
|
661
|
+
assert result_dir.exists()
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def test_download_version_from_url_writes_jar(mocker, mock_cache):
|
|
665
|
+
"""The jar file is written into the version directory."""
|
|
666
|
+
|
|
667
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
668
|
+
fake_ts = "2026-04-06T12:51:28Z"
|
|
669
|
+
mocker.patch("tlaplus_cli.version_manager._utc_now_iso", return_value=fake_ts)
|
|
670
|
+
mock_response = mocker.MagicMock()
|
|
671
|
+
mock_response.raise_for_status = mocker.MagicMock()
|
|
672
|
+
mock_response.headers = {"content-length": "16"}
|
|
673
|
+
mock_response.iter_content.return_value = [b"fake jar content"]
|
|
674
|
+
mocker.patch("tlaplus_cli.version_manager.requests.get", return_value=mock_response)
|
|
675
|
+
mocker.patch("tlaplus_cli.version_manager.write_version_metadata_from_url")
|
|
676
|
+
|
|
677
|
+
result_dir = download_version_from_url(url)
|
|
678
|
+
jar = result_dir / "tla2tools.jar"
|
|
679
|
+
assert jar.exists()
|
|
680
|
+
assert jar.read_bytes() == b"fake jar content"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def test_download_version_from_url_no_version_raises(mocker, mock_cache):
|
|
684
|
+
"""A URL with no semver segment raises ValueError."""
|
|
685
|
+
|
|
686
|
+
url = "https://example.com/latest/tla2tools.jar"
|
|
687
|
+
with pytest.raises(ValueError, match="version"):
|
|
688
|
+
download_version_from_url(url)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_download_version_from_url_cleans_up_on_failure(mocker, mock_cache):
|
|
692
|
+
"""The version directory is removed if the download fails."""
|
|
693
|
+
|
|
694
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
695
|
+
fake_ts = "2026-04-06T12:51:28Z"
|
|
696
|
+
mocker.patch("tlaplus_cli.version_manager._utc_now_iso", return_value=fake_ts)
|
|
697
|
+
mocker.patch(
|
|
698
|
+
"tlaplus_cli.version_manager.requests.get",
|
|
699
|
+
side_effect=Exception("network error"),
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
with pytest.raises(Exception, match="network error"):
|
|
703
|
+
download_version_from_url(url)
|
|
704
|
+
|
|
705
|
+
tools_dir = mock_cache / "tools"
|
|
706
|
+
version_dir = tools_dir / f"v1.9.0-{fake_ts}"
|
|
707
|
+
assert not version_dir.exists()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
# Custom URL install — CLI integration tests
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
def test_install_from_url_success(mock_cache, mock_load_config, mocker):
|
|
715
|
+
"""Installing from a valid URL echoes 'Download complete' and auto-pins."""
|
|
716
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
717
|
+
fake_ts = "2026-04-06T12:51:28Z"
|
|
718
|
+
|
|
719
|
+
def _fake_download_url(u):
|
|
720
|
+
tools_dir = mock_cache / "tools"
|
|
721
|
+
version_dir = tools_dir / f"v1.9.0-{fake_ts}"
|
|
722
|
+
version_dir.mkdir(parents=True, exist_ok=True)
|
|
723
|
+
(version_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
724
|
+
return version_dir
|
|
725
|
+
|
|
726
|
+
mocker.patch(
|
|
727
|
+
"tlaplus_cli.tools_manager.download_version_from_url",
|
|
728
|
+
side_effect=_fake_download_url,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
result = runner.invoke(app, ["tools", "install", url])
|
|
732
|
+
assert result.exit_code == 0
|
|
733
|
+
assert "Download complete" in result.stdout
|
|
734
|
+
assert "Auto-pinning" in result.stdout
|
|
735
|
+
|
|
736
|
+
pin_file = mock_cache / "tools" / "tools-pinned-version.txt"
|
|
737
|
+
assert pin_file.read_text().strip() == f"v1.9.0-{fake_ts}"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def test_install_from_url_no_version_segment(mock_cache, mock_load_config, mocker):
|
|
741
|
+
"""A URL without a semver segment prints an error and exits with code 1."""
|
|
742
|
+
url = "https://example.com/latest/tla2tools.jar"
|
|
743
|
+
|
|
744
|
+
mocker.patch(
|
|
745
|
+
"tlaplus_cli.tools_manager.download_version_from_url",
|
|
746
|
+
side_effect=ValueError(
|
|
747
|
+
'could not extract a version name from the URL. '
|
|
748
|
+
'The URL must contain a version segment (e.g. "v1.8.0").'
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
result = runner.invoke(app, ["tools", "install", url])
|
|
753
|
+
assert result.exit_code == 1
|
|
754
|
+
assert "could not extract a version name" in result.output
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def test_install_from_url_does_not_move_existing_pin(mock_cache, mock_load_config, mocker):
|
|
758
|
+
"""URL install does NOT auto-pin when a pin already exists."""
|
|
759
|
+
tools_dir = mock_cache / "tools"
|
|
760
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
pin_file = tools_dir / "tools-pinned-version.txt"
|
|
762
|
+
pin_file.write_text("v1.8.0-aaaaaaa")
|
|
763
|
+
(tools_dir / "v1.8.0-aaaaaaa").mkdir()
|
|
764
|
+
|
|
765
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
766
|
+
fake_ts = "2026-04-06T12:51:28Z"
|
|
767
|
+
|
|
768
|
+
def _fake_download_url(u):
|
|
769
|
+
version_dir = tools_dir / f"v1.9.0-{fake_ts}"
|
|
770
|
+
version_dir.mkdir(parents=True, exist_ok=True)
|
|
771
|
+
(version_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
772
|
+
return version_dir
|
|
773
|
+
|
|
774
|
+
mocker.patch(
|
|
775
|
+
"tlaplus_cli.tools_manager.download_version_from_url",
|
|
776
|
+
side_effect=_fake_download_url,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
result = runner.invoke(app, ["tools", "install", url])
|
|
780
|
+
assert result.exit_code == 0
|
|
781
|
+
assert pin_file.read_text().strip() == "v1.8.0-aaaaaaa"
|
|
782
|
+
assert "Auto-pinning" not in result.stdout
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def test_install_from_url_network_error(mock_cache, mock_load_config, mocker):
|
|
786
|
+
"""A network error during URL download prints the error and exits with code 1."""
|
|
787
|
+
url = "https://example.com/v1.9.0/tla2tools.jar"
|
|
788
|
+
|
|
789
|
+
mocker.patch(
|
|
790
|
+
"tlaplus_cli.tools_manager.download_version_from_url",
|
|
791
|
+
side_effect=Exception("connection refused"),
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
result = runner.invoke(app, ["tools", "install", url])
|
|
795
|
+
assert result.exit_code == 1
|
|
796
|
+
assert "Failed to download" in result.output
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def test_list_shows_url_installed_version(mock_github_api, mock_cache, mock_load_config):
|
|
800
|
+
"""URL-installed version (timestamp tag) appears correctly in tla tools list."""
|
|
801
|
+
tools_dir = mock_cache / "tools"
|
|
802
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
803
|
+
|
|
804
|
+
ts = "2026-04-06T12:51:28Z"
|
|
805
|
+
url_dir = tools_dir / f"v1.9.0-{ts}"
|
|
806
|
+
url_dir.mkdir()
|
|
807
|
+
(url_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
808
|
+
|
|
809
|
+
result = runner.invoke(app, ["tools", "list"])
|
|
810
|
+
assert result.exit_code == 0
|
|
811
|
+
assert "v1.9.0" in result.stdout
|
|
812
|
+
assert ts in result.stdout
|
|
813
|
+
assert "local only" in result.stdout
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def test_meta_sync_skips_url_installed_version(mock_github_api, mock_cache, mock_load_config, mocker):
|
|
817
|
+
"""meta sync gracefully skips versions not found in the remote list."""
|
|
818
|
+
tools_dir = mock_cache / "tools"
|
|
819
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
820
|
+
|
|
821
|
+
ts = "2026-04-06T12:51:28Z"
|
|
822
|
+
url_dir = tools_dir / f"v1.9.0-{ts}"
|
|
823
|
+
url_dir.mkdir()
|
|
824
|
+
(url_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
825
|
+
|
|
826
|
+
mocker.patch("tlaplus_cli.tools_manager.write_version_metadata")
|
|
827
|
+
|
|
828
|
+
result = runner.invoke(app, ["tools", "meta", "sync"])
|
|
829
|
+
assert result.exit_code == 0
|
|
830
|
+
assert "Could not find remote data for v1.9.0" in result.output
|
|
@@ -7,8 +7,12 @@ from tlaplus_cli.version_manager import (
|
|
|
7
7
|
LocalVersion,
|
|
8
8
|
RemoteVersion,
|
|
9
9
|
_process_remote_versions,
|
|
10
|
+
extract_version_from_url,
|
|
11
|
+
is_url,
|
|
12
|
+
list_local_versions,
|
|
10
13
|
resolve_latest_version,
|
|
11
14
|
write_version_metadata,
|
|
15
|
+
write_version_metadata_from_url,
|
|
12
16
|
)
|
|
13
17
|
|
|
14
18
|
|
|
@@ -191,3 +195,128 @@ def test_write_version_metadata(tmp_path, mocker):
|
|
|
191
195
|
assert data["tlc2_version_string"] == "TLC2 Version 2.18 of 27 March 2026 (revision 1.8.0)"
|
|
192
196
|
assert data["prerelease"] is True
|
|
193
197
|
assert data["download_url"] == "http://example.com/tla2tools.jar"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- is_url ---
|
|
201
|
+
|
|
202
|
+
def test_is_url_https():
|
|
203
|
+
assert is_url("https://example.com/v1.8.0/tla2tools.jar") is True
|
|
204
|
+
|
|
205
|
+
def test_is_url_http():
|
|
206
|
+
assert is_url("http://example.com/tla2tools.jar") is True
|
|
207
|
+
|
|
208
|
+
def test_is_url_version_string():
|
|
209
|
+
assert is_url("v1.8.0") is False
|
|
210
|
+
|
|
211
|
+
def test_is_url_empty():
|
|
212
|
+
assert is_url("") is False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- extract_version_from_url ---
|
|
216
|
+
|
|
217
|
+
def test_extract_version_direct_parent():
|
|
218
|
+
url = "https://example.com/v1.8.0/tla2tools.jar"
|
|
219
|
+
assert extract_version_from_url(url) == "v1.8.0"
|
|
220
|
+
|
|
221
|
+
def test_extract_version_nested():
|
|
222
|
+
url = "https://example.com/custom/v1.8.0/build/tla2tools.jar"
|
|
223
|
+
assert extract_version_from_url(url) == "v1.8.0"
|
|
224
|
+
|
|
225
|
+
def test_extract_version_no_v_prefix():
|
|
226
|
+
url = "https://example.com/1.8.0/tla2tools.jar"
|
|
227
|
+
assert extract_version_from_url(url) == "1.8.0"
|
|
228
|
+
|
|
229
|
+
def test_extract_version_not_found():
|
|
230
|
+
url = "https://example.com/custom/latest/tla2tools.jar"
|
|
231
|
+
assert extract_version_from_url(url) is None
|
|
232
|
+
|
|
233
|
+
def test_extract_version_returns_first_match():
|
|
234
|
+
# Two version-like segments — must return the first one encountered
|
|
235
|
+
url = "https://example.com/v1.8.0/dist/v2.0.0/tla2tools.jar"
|
|
236
|
+
assert extract_version_from_url(url) == "v1.8.0"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- write_version_metadata_from_url ---
|
|
240
|
+
|
|
241
|
+
def test_write_version_metadata_from_url_creates_file(tmp_path, mocker):
|
|
242
|
+
|
|
243
|
+
mock_run = mocker.patch("tlaplus_cli.version_manager.subprocess.run")
|
|
244
|
+
mock_run.return_value.stdout = "TLC2 Version 2.20\nSome extra line"
|
|
245
|
+
|
|
246
|
+
write_version_metadata_from_url(
|
|
247
|
+
tmp_path,
|
|
248
|
+
version_name="v1.9.0",
|
|
249
|
+
tag="2026-04-06T12:51:28Z",
|
|
250
|
+
url="https://example.com/v1.9.0/tla2tools.jar",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
meta_file = tmp_path / "meta-tla2tools.json"
|
|
254
|
+
assert meta_file.exists()
|
|
255
|
+
|
|
256
|
+
with meta_file.open() as f:
|
|
257
|
+
data = json.load(f)
|
|
258
|
+
|
|
259
|
+
assert data["tag_name"] == "v1.9.0"
|
|
260
|
+
assert data["tag"] == "2026-04-06T12:51:28Z"
|
|
261
|
+
assert data["download_url"] == "https://example.com/v1.9.0/tla2tools.jar"
|
|
262
|
+
assert data["sha"] == ""
|
|
263
|
+
assert data["published_at"] == ""
|
|
264
|
+
assert data["prerelease"] is False
|
|
265
|
+
assert data["tlc2_version_string"] == "TLC2 Version 2.20"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_write_version_metadata_from_url_handles_subprocess_failure(tmp_path, mocker):
|
|
269
|
+
|
|
270
|
+
mocker.patch(
|
|
271
|
+
"tlaplus_cli.version_manager.subprocess.run",
|
|
272
|
+
side_effect=Exception("java not found"),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
write_version_metadata_from_url(
|
|
276
|
+
tmp_path,
|
|
277
|
+
version_name="v1.9.0",
|
|
278
|
+
tag="2026-04-06T12:51:28Z",
|
|
279
|
+
url="https://example.com/v1.9.0/tla2tools.jar",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
meta_file = tmp_path / "meta-tla2tools.json"
|
|
283
|
+
with meta_file.open() as f:
|
|
284
|
+
data = json.load(f)
|
|
285
|
+
assert data["tlc2_version_string"] == ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- list_local_versions with timestamp tag ---
|
|
289
|
+
|
|
290
|
+
def test_list_local_versions_timestamp_tag(tmp_path, mocker):
|
|
291
|
+
"""URL-installed versions whose suffix is a timestamp are parsed correctly."""
|
|
292
|
+
|
|
293
|
+
mocker.patch("tlaplus_cli.version_manager.cache_dir", return_value=tmp_path)
|
|
294
|
+
tools_dir = tmp_path / "tools"
|
|
295
|
+
tools_dir.mkdir()
|
|
296
|
+
|
|
297
|
+
ts_dir = tools_dir / "v1.9.0-2026-04-06T12:51:28Z"
|
|
298
|
+
ts_dir.mkdir()
|
|
299
|
+
(ts_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
300
|
+
|
|
301
|
+
versions = list_local_versions()
|
|
302
|
+
assert len(versions) == 1
|
|
303
|
+
lv = versions[0]
|
|
304
|
+
assert lv.name == "v1.9.0"
|
|
305
|
+
assert lv.short_sha == "2026-04-06T12:51:28Z"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_list_local_versions_sha_tag(tmp_path, mocker):
|
|
309
|
+
"""Standard SHA-tagged versions still parse correctly after the split change."""
|
|
310
|
+
|
|
311
|
+
mocker.patch("tlaplus_cli.version_manager.cache_dir", return_value=tmp_path)
|
|
312
|
+
tools_dir = tmp_path / "tools"
|
|
313
|
+
tools_dir.mkdir()
|
|
314
|
+
|
|
315
|
+
sha_dir = tools_dir / "v1.8.0-aaaaaaa"
|
|
316
|
+
sha_dir.mkdir()
|
|
317
|
+
(sha_dir / "tla2tools.jar").write_bytes(b"jar")
|
|
318
|
+
|
|
319
|
+
versions = list_local_versions()
|
|
320
|
+
assert len(versions) == 1
|
|
321
|
+
assert versions[0].name == "v1.8.0"
|
|
322
|
+
assert versions[0].short_sha == "aaaaaaa"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|