tlaplus-cli 0.3.3__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.
Files changed (34) hide show
  1. {tlaplus_cli-0.3.3/src/tlaplus_cli.egg-info → tlaplus_cli-0.3.4}/PKG-INFO +1 -1
  2. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/pyproject.toml +1 -1
  3. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/tools_manager.py +21 -0
  4. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/version_manager.py +119 -1
  5. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4/src/tlaplus_cli.egg-info}/PKG-INFO +1 -1
  6. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_tools_manager.py +194 -0
  7. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_version_manager.py +129 -0
  8. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/LICENSE +0 -0
  9. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/MANIFEST.in +0 -0
  10. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/README.md +0 -0
  11. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/setup.cfg +0 -0
  12. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/__init__.py +0 -0
  13. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/build_tlc_module.py +0 -0
  14. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/check_java.py +0 -0
  15. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/cli.py +0 -0
  16. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/config.py +0 -0
  17. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/project.py +0 -0
  18. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/py.typed +0 -0
  19. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/resources/__init__.py +0 -0
  20. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/resources/default_config.yaml +0 -0
  21. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/run_tlc.py +0 -0
  22. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli/settings.py +0 -0
  23. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/SOURCES.txt +0 -0
  24. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/dependency_links.txt +0 -0
  25. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/entry_points.txt +0 -0
  26. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/requires.txt +0 -0
  27. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/src/tlaplus_cli.egg-info/top_level.txt +0 -0
  28. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_build_tlc_module.py +0 -0
  29. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_check_java.py +0 -0
  30. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_cli.py +0 -0
  31. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_java_module_naming.py +0 -0
  32. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_project.py +0 -0
  33. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_run_tlc.py +0 -0
  34. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.4}/tests/test_spec_resolution.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: TLA+ tools: download TLC, compile custom modules, run model checker
5
5
  Author-email: Denis Nikolskiy <codeomatics@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tlaplus-cli"
7
- version = "0.3.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"
@@ -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
- parts = d.name.rsplit("-", 1)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: TLA+ tools: download TLC, compile custom modules, run model checker
5
5
  Author-email: Denis Nikolskiy <codeomatics@gmail.com>
6
6
  License-Expression: MIT
@@ -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