tlaplus-cli 0.3.3__tar.gz → 0.3.5__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 (39) hide show
  1. {tlaplus_cli-0.3.3/src/tlaplus_cli.egg-info → tlaplus_cli-0.3.5}/PKG-INFO +11 -1
  2. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/README.md +10 -0
  3. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/pyproject.toml +1 -1
  4. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/build_tlc_module.py +16 -3
  5. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/cli.py +2 -0
  6. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/config.py +21 -1
  7. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/run_tlc.py +9 -0
  8. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/settings.py +1 -0
  9. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/tools_manager.py +21 -0
  10. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/version_manager.py +118 -2
  11. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5/src/tlaplus_cli.egg-info}/PKG-INFO +11 -1
  12. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli.egg-info/SOURCES.txt +5 -8
  13. tlaplus_cli-0.3.5/tests/test_build_modules_path.py +50 -0
  14. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/tests/test_cli.py +1 -4
  15. tlaplus_cli-0.3.5/tests/test_config.py +99 -0
  16. tlaplus_cli-0.3.5/tests/test_modules_path.py +62 -0
  17. tlaplus_cli-0.3.5/tests/test_settings.py +38 -0
  18. tlaplus_cli-0.3.5/tests/test_tlc_modules_path.py +43 -0
  19. tlaplus_cli-0.3.3/tests/test_build_tlc_module.py +0 -161
  20. tlaplus_cli-0.3.3/tests/test_check_java.py +0 -69
  21. tlaplus_cli-0.3.3/tests/test_java_module_naming.py +0 -86
  22. tlaplus_cli-0.3.3/tests/test_project.py +0 -50
  23. tlaplus_cli-0.3.3/tests/test_run_tlc.py +0 -96
  24. tlaplus_cli-0.3.3/tests/test_spec_resolution.py +0 -200
  25. tlaplus_cli-0.3.3/tests/test_tools_manager.py +0 -636
  26. tlaplus_cli-0.3.3/tests/test_version_manager.py +0 -193
  27. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/LICENSE +0 -0
  28. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/MANIFEST.in +0 -0
  29. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/setup.cfg +0 -0
  30. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/__init__.py +0 -0
  31. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/check_java.py +0 -0
  32. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/project.py +0 -0
  33. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/py.typed +0 -0
  34. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/resources/__init__.py +0 -0
  35. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli/resources/default_config.yaml +0 -0
  36. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli.egg-info/dependency_links.txt +0 -0
  37. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli.egg-info/entry_points.txt +0 -0
  38. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli.egg-info/requires.txt +0 -0
  39. {tlaplus_cli-0.3.3 → tlaplus_cli-0.3.5}/src/tlaplus_cli.egg-info/top_level.txt +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.5
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
@@ -170,6 +170,14 @@ Compile modules in a specific project path:
170
170
  tla modules build path/to/project
171
171
  ```
172
172
 
173
+ Configure a persistent custom modules path:
174
+ ```bash
175
+ tla modules path /path/to/custom/modules
176
+ ```
177
+
178
+ > [!TIP]
179
+ > Setting a persistent `module_path` allows `tla modules build` and `tla tlc` to resolve your Java overrides regardless of where the commands are executed.
180
+
173
181
  Verbose output:
174
182
  ```bash
175
183
  tla modules build --verbose
@@ -221,6 +229,8 @@ tlc:
221
229
  java_class: tlc2.TLC
222
230
  overrides_class: tlc2.overrides.TLCOverrides
223
231
 
232
+ module_path: null # (Optional) Persistent custom modules path
233
+
224
234
  java:
225
235
  min_version: 11
226
236
  opts:
@@ -141,6 +141,14 @@ Compile modules in a specific project path:
141
141
  tla modules build path/to/project
142
142
  ```
143
143
 
144
+ Configure a persistent custom modules path:
145
+ ```bash
146
+ tla modules path /path/to/custom/modules
147
+ ```
148
+
149
+ > [!TIP]
150
+ > Setting a persistent `module_path` allows `tla modules build` and `tla tlc` to resolve your Java overrides regardless of where the commands are executed.
151
+
144
152
  Verbose output:
145
153
  ```bash
146
154
  tla modules build --verbose
@@ -192,6 +200,8 @@ tlc:
192
200
  java_class: tlc2.TLC
193
201
  overrides_class: tlc2.overrides.TLCOverrides
194
202
 
203
+ module_path: null # (Optional) Persistent custom modules path
204
+
195
205
  java:
196
206
  min_version: 11
197
207
  opts:
@@ -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.5"
8
8
  description = "TLA+ tools: download TLC, compile custom modules, run model checker"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import typer
8
8
 
9
- from tlaplus_cli.config import cache_dir, load_config, workspace_root
9
+ from tlaplus_cli.config import cache_dir, load_config, save_config, workspace_root
10
10
  from tlaplus_cli.version_manager import get_pinned_version_dir
11
11
 
12
12
 
@@ -28,9 +28,8 @@ def build(
28
28
  typer.echo("Error: tla2tools.jar not found.\nRun 'tla tools install' first.", err=True)
29
29
  raise typer.Exit(1)
30
30
 
31
- modules_dir = base_dir / config.workspace.modules_dir
31
+ modules_dir = Path(config.module_path) if config.module_path else base_dir / config.workspace.modules_dir
32
32
  classes_dir = base_dir / config.workspace.classes_dir
33
-
34
33
  lib_dir = base_dir / "lib"
35
34
  lib_jars = sorted(lib_dir.glob("*.jar")) if lib_dir.is_dir() else []
36
35
  classpath = os.pathsep.join([str(jar_path)] + [str(j) for j in lib_jars])
@@ -71,3 +70,17 @@ def build(
71
70
  except FileNotFoundError:
72
71
  typer.echo("Error: 'javac' not found. Ensure JDK is installed and in PATH.", err=True)
73
72
  raise typer.Exit(1) from None
73
+
74
+
75
+ def set_modules_path(path: str = typer.Argument(..., help="Path to the custom Java modules directory.")) -> None:
76
+ """Configure a persistent custom Java modules path."""
77
+ config_obj = load_config()
78
+ p = Path(path).resolve()
79
+
80
+ if not p.is_dir():
81
+ typer.echo(f"Error: Path does not exist or is not a directory: {p}", err=True)
82
+ raise typer.Exit(1)
83
+
84
+ config_obj.module_path = str(p)
85
+ save_config(config_obj)
86
+ typer.echo(f"Modules path updated to: {p}")
@@ -5,6 +5,7 @@ import importlib.metadata
5
5
  import typer
6
6
 
7
7
  from tlaplus_cli.build_tlc_module import build as build_tlc_cmd
8
+ from tlaplus_cli.build_tlc_module import set_modules_path
8
9
  from tlaplus_cli.check_java import check_java_version, get_java_version
9
10
  from tlaplus_cli.config import load_config
10
11
  from tlaplus_cli.run_tlc import tlc as run_tlc_cmd
@@ -50,6 +51,7 @@ def root(
50
51
  # Attach typer subgroups
51
52
  modules_app = typer.Typer(name="modules", help="Manage TLA+ Java modules.", no_args_is_help=True)
52
53
  modules_app.command(name="build")(build_tlc_cmd)
54
+ modules_app.command(name="path")(set_modules_path)
53
55
 
54
56
  app.add_typer(modules_app, name="modules")
55
57
  app.add_typer(tools_app, name="tools")
@@ -17,11 +17,31 @@ def load_config() -> Settings:
17
17
  Creates a default config on first run.
18
18
  """
19
19
  cp = _ensure_config()
20
- with cp.open() as f:
20
+ with cp.open(encoding="utf-8") as f:
21
21
  data = yaml.safe_load(f)
22
22
  return Settings.model_validate(data)
23
23
 
24
24
 
25
+ def save_config(settings: Settings) -> None:
26
+ """Save config to the user config directory.
27
+
28
+ Uses a defensive deep copy of settings.
29
+ """
30
+ cp = config_path()
31
+ # defensive deep copy as per plan
32
+ base_settings = settings.model_copy(deep=True)
33
+
34
+ # Convert to dict, excluding None values for a cleaner config if desired,
35
+ # but the plan doesn't specify exclusion. Pydantic's model_dump is good.
36
+ data = base_settings.model_dump(mode="json")
37
+
38
+ with cp.open("w", encoding="utf-8") as f:
39
+ yaml.safe_dump(data, f, default_flow_style=False)
40
+
41
+ # Clear cache to ensure subsequent loads get the new data
42
+ load_config.cache_clear()
43
+
44
+
25
45
  def config_dir() -> Path:
26
46
  """OS-standard user config directory for this app."""
27
47
  return Path(platformdirs.user_config_dir(_APP_NAME))
@@ -85,6 +85,15 @@ def tlc(
85
85
 
86
86
  classpath_parts = [str(jar_path)]
87
87
  extra_jvm_opts: list[str] = []
88
+
89
+ if config.module_path:
90
+ custom_path = Path(config.module_path)
91
+ if custom_path.is_dir():
92
+ # Phase 4: safely append the custom directory path to the Java -cp mechanism
93
+ classpath_parts.append(str(custom_path))
94
+ # Also add to TLA-Library for convenience if TLA modules are there
95
+ extra_jvm_opts.append(f"-DTLA-Library={custom_path}")
96
+
88
97
  if project_root:
89
98
  classes_path = project_root / config.workspace.classes_dir
90
99
  if classes_path.is_dir():
@@ -46,3 +46,4 @@ class Settings(BaseModel):
46
46
  workspace: WorkspaceConfig
47
47
  tlc: TlcConfig
48
48
  java: JavaConfig = Field(default_factory=JavaConfig)
49
+ module_path: str | None = None
@@ -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,118 @@ 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 = 'could not extract a version name from the URL. The URL must contain a version segment (e.g. "v1.8.0").'
62
+ raise ValueError(msg)
63
+
64
+ tag = _utc_now_iso()
65
+ tools_dir = get_tools_dir()
66
+ version_dir = tools_dir / f"{version_name}-{tag}"
67
+ version_dir.mkdir(parents=True, exist_ok=True)
68
+ jar_path = version_dir / "tla2tools.jar"
69
+
70
+ try:
71
+ response = requests.get(
72
+ url,
73
+ stream=True,
74
+ timeout=30,
75
+ headers={"User-Agent": "tlaplus-cli"},
76
+ )
77
+ response.raise_for_status()
78
+ total = int(response.headers.get("content-length", 0))
79
+
80
+ with Progress(
81
+ "[progress.description]{task.description}",
82
+ BarColumn(),
83
+ DownloadColumn(),
84
+ TransferSpeedColumn(),
85
+ ) as progress:
86
+ task = progress.add_task(f"Downloading {version_name}...", total=total or None)
87
+ with jar_path.open("wb") as f:
88
+ for chunk in response.iter_content(chunk_size=8192):
89
+ f.write(chunk)
90
+ progress.update(task, advance=len(chunk))
91
+ except Exception:
92
+ shutil.rmtree(version_dir, ignore_errors=True)
93
+ raise
94
+
95
+ write_version_metadata_from_url(version_dir, version_name=version_name, tag=tag, url=url)
96
+ return version_dir
97
+
98
+
99
+ def write_version_metadata_from_url(
100
+ version_dir: Path,
101
+ *,
102
+ version_name: str,
103
+ tag: str,
104
+ url: str,
105
+ ) -> None:
106
+ """Write meta-tla2tools.json for a URL-sourced install."""
107
+ tlc2_version_string = ""
108
+ try:
109
+ result = subprocess.run(
110
+ ["java", "-cp", "tla2tools.jar", "tlc2.TLC", "-version"],
111
+ cwd=version_dir,
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ )
116
+ if result.stdout:
117
+ tlc2_version_string = result.stdout.strip().split("\n")[0]
118
+ except Exception as e:
119
+ typer.echo(f"Warning: Failed to extract TLC version string: {e}", err=True)
120
+
121
+ meta_file = version_dir / "meta-tla2tools.json"
122
+ metadata = {
123
+ "tag_name": version_name,
124
+ "sha": "",
125
+ "published_at": "",
126
+ "tlc2_version_string": tlc2_version_string,
127
+ "prerelease": False,
128
+ "download_url": url,
129
+ "tag": tag,
130
+ }
131
+
132
+ try:
133
+ with meta_file.open("w") as f:
134
+ json.dump(metadata, f, indent=2)
135
+ except Exception as e:
136
+ typer.echo(f"Warning: Failed to write metadata: {e}", err=True)
137
+
138
+
25
139
  def _parse_semver(name: str) -> tuple[int, int, int] | None:
26
140
  """Try to extract (major, minor, patch) from a version name like 'v1.8.0'."""
27
141
  m = _SEMVER_RE.match(name)
@@ -173,7 +287,8 @@ def list_local_versions() -> list[LocalVersion]:
173
287
  result = []
174
288
  for d in tools_dir.iterdir():
175
289
  if d.is_dir() and not d.is_symlink():
176
- parts = d.name.rsplit("-", 1)
290
+ # Split on the FIRST hyphen so timestamp suffixes are preserved whole
291
+ parts = d.name.split("-", 1)
177
292
  if len(parts) == 2:
178
293
  result.append(LocalVersion(name=parts[0], short_sha=parts[1], path=d))
179
294
  return result
@@ -301,7 +416,8 @@ def _fetch_from_api(
301
416
  releases_response = requests.get(releases_url, params=params, timeout=10)
302
417
  releases_response.raise_for_status()
303
418
  releases_data: list[dict[str, Any]] = releases_response.json()
304
- except requests.RequestException:
419
+ except Exception as e:
420
+ typer.echo(f"⚠ Warning: Failed to fetch remote versions: {e}", err=True)
305
421
  return None
306
422
  else:
307
423
  return tags_data, releases_data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.3.3
3
+ Version: 0.3.5
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
@@ -170,6 +170,14 @@ Compile modules in a specific project path:
170
170
  tla modules build path/to/project
171
171
  ```
172
172
 
173
+ Configure a persistent custom modules path:
174
+ ```bash
175
+ tla modules path /path/to/custom/modules
176
+ ```
177
+
178
+ > [!TIP]
179
+ > Setting a persistent `module_path` allows `tla modules build` and `tla tlc` to resolve your Java overrides regardless of where the commands are executed.
180
+
173
181
  Verbose output:
174
182
  ```bash
175
183
  tla modules build --verbose
@@ -221,6 +229,8 @@ tlc:
221
229
  java_class: tlc2.TLC
222
230
  overrides_class: tlc2.overrides.TLCOverrides
223
231
 
232
+ module_path: null # (Optional) Persistent custom modules path
233
+
224
234
  java:
225
235
  min_version: 11
226
236
  opts:
@@ -21,12 +21,9 @@ src/tlaplus_cli.egg-info/requires.txt
21
21
  src/tlaplus_cli.egg-info/top_level.txt
22
22
  src/tlaplus_cli/resources/__init__.py
23
23
  src/tlaplus_cli/resources/default_config.yaml
24
- tests/test_build_tlc_module.py
25
- tests/test_check_java.py
24
+ tests/test_build_modules_path.py
26
25
  tests/test_cli.py
27
- tests/test_java_module_naming.py
28
- tests/test_project.py
29
- tests/test_run_tlc.py
30
- tests/test_spec_resolution.py
31
- tests/test_tools_manager.py
32
- tests/test_version_manager.py
26
+ tests/test_config.py
27
+ tests/test_modules_path.py
28
+ tests/test_settings.py
29
+ tests/test_tlc_modules_path.py
@@ -0,0 +1,50 @@
1
+ from tlaplus_cli.cli import app
2
+
3
+
4
+ def test_build_uses_configured_module_path(mocker, tmp_path, base_settings, runner):
5
+ """Test that build uses module_path from config if available."""
6
+ # Custom module path
7
+ custom_modules = tmp_path / "custom_modules"
8
+ custom_modules.mkdir()
9
+ (custom_modules / "MyModule.java").write_text("class MyModule {}")
10
+
11
+ # Update settings
12
+ settings = base_settings.model_copy(deep=True)
13
+ settings.module_path = str(custom_modules)
14
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=settings)
15
+
16
+ # Mock subprocess.run
17
+ mock_run = mocker.patch("tlaplus_cli.build_tlc_module.subprocess.run")
18
+ mock_run.return_value.returncode = 0
19
+
20
+ # Mock jar path
21
+ pinned_dir = tmp_path / "tools" / "v1.8.0"
22
+ pinned_dir.mkdir(parents=True)
23
+ (pinned_dir / "tla2tools.jar").write_bytes(b"fake")
24
+ mocker.patch("tlaplus_cli.build_tlc_module.get_pinned_version_dir", return_value=pinned_dir)
25
+
26
+ result = runner.invoke(app, ["modules", "build"])
27
+
28
+ assert result.exit_code == 0
29
+ args, _ = mock_run.call_args
30
+ cmd = args[0]
31
+ # Source file should be from custom_modules
32
+ assert str(custom_modules / "MyModule.java") in cmd
33
+
34
+
35
+ def test_build_fails_if_module_path_missing(mocker, tmp_path, base_settings, runner):
36
+ """Test that build fails if the configured module_path does not exist."""
37
+ settings = base_settings.model_copy(deep=True)
38
+ settings.module_path = str(tmp_path / "non_existent")
39
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=settings)
40
+
41
+ # Mock jar path
42
+ pinned_dir = tmp_path / "tools" / "v1.8.0"
43
+ pinned_dir.mkdir(parents=True)
44
+ (pinned_dir / "tla2tools.jar").write_bytes(b"fake")
45
+ mocker.patch("tlaplus_cli.build_tlc_module.get_pinned_version_dir", return_value=pinned_dir)
46
+
47
+ result = runner.invoke(app, ["modules", "build"])
48
+
49
+ assert result.exit_code == 1
50
+ assert "Error: modules directory not found" in result.output
@@ -1,11 +1,8 @@
1
1
  import pytest
2
2
  import typer
3
- from typer.testing import CliRunner
4
3
 
5
4
  from tlaplus_cli.cli import app, version_callback
6
5
 
7
- runner = CliRunner()
8
-
9
6
 
10
7
  def test_version_callback_exits(mocker):
11
8
  """Test version callback exits after printing."""
@@ -22,7 +19,7 @@ def test_version_callback_exits(mocker):
22
19
  mock_metadata.assert_called_once_with("tlaplus-cli")
23
20
 
24
21
 
25
- def test_cli_version_flag(mocker):
22
+ def test_cli_version_flag(mocker, runner):
26
23
  """Test 'tla --version' command."""
27
24
  mock_metadata = mocker.patch("importlib.metadata.metadata")
28
25
  mock_metadata.return_value = {
@@ -0,0 +1,99 @@
1
+ from tlaplus_cli import config
2
+
3
+
4
+ def test_config_paths(mocker, tmp_path):
5
+ """Test config_dir, config_path, cache_dir return correct paths."""
6
+ mocker.patch("platformdirs.user_config_dir", return_value=str(tmp_path / "config"))
7
+ mocker.patch("platformdirs.user_cache_dir", return_value=str(tmp_path / "cache"))
8
+
9
+ assert config.config_dir() == tmp_path / "config"
10
+ assert config.config_path() == tmp_path / "config" / "config.yaml"
11
+ assert config.cache_dir() == tmp_path / "cache"
12
+
13
+
14
+ def test_ensure_config_creates_default(mocker, tmp_path):
15
+ """_ensure_config creates the config file if it doesn't exist."""
16
+ config_dir = tmp_path / "config"
17
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
18
+ config_path = config_dir / "config.yaml"
19
+
20
+ assert not config_path.exists()
21
+ path = config._ensure_config()
22
+ assert path == config_path
23
+ assert config_path.exists()
24
+ assert "workspace:" in config_path.read_text()
25
+
26
+
27
+ def test_load_config_caching(mocker, tmp_path):
28
+ """load_config should be cached."""
29
+ config.load_config.cache_clear()
30
+
31
+ config_dir = tmp_path / "config"
32
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
33
+ config._ensure_config()
34
+
35
+ # First call
36
+ c1 = config.load_config()
37
+ # Second call should return the same object from cache
38
+ c2 = config.load_config()
39
+ assert c1 is c2
40
+
41
+
42
+ def test_workspace_root_absolute(mocker, tmp_path):
43
+ """workspace_root returns absolute path when config has absolute path."""
44
+ abs_path = (tmp_path / "my_ws").absolute()
45
+ mock_settings = mocker.MagicMock()
46
+ mock_settings.workspace.root = str(abs_path)
47
+ mocker.patch("tlaplus_cli.config.load_config", return_value=mock_settings)
48
+
49
+ assert config.workspace_root() == abs_path
50
+
51
+
52
+ def test_load_module_path(mocker, tmp_path):
53
+ """Test that module_path can be loaded from config."""
54
+ config.load_config.cache_clear()
55
+ config_dir = tmp_path / "config"
56
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
57
+ config_path = config_dir / "config.yaml"
58
+ config_path.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ config_content = """
61
+ tla:
62
+ urls:
63
+ tags: https://example.com/tags
64
+ releases: https://example.com/releases
65
+ per_page: 30
66
+ workspace:
67
+ root: .
68
+ spec_dir: spec
69
+ modules_dir: modules
70
+ classes_dir: classes
71
+ tlc:
72
+ java_class: tlc2.TLC
73
+ overrides_class: tlc2.overrides.TLCOverrides
74
+ module_path: /custom/path
75
+ """
76
+ config_path.write_text(config_content)
77
+
78
+ cfg = config.load_config()
79
+ assert cfg.module_path == "/custom/path"
80
+
81
+
82
+ def test_save_config(mocker, tmp_path):
83
+ """Test saving config preserves module_path and uses deep copy."""
84
+ config.load_config.cache_clear()
85
+ config_dir = tmp_path / "config"
86
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
87
+ config_path = config_dir / "config.yaml"
88
+ config_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ cfg = config.load_config()
91
+ cfg.module_path = "/new/path"
92
+
93
+ config.save_config(cfg)
94
+
95
+ # Reload and check
96
+ config.load_config.cache_clear()
97
+ new_cfg = config.load_config()
98
+ assert new_cfg.module_path == "/new/path"
99
+ assert "module_path: /new/path" in config_path.read_text()
@@ -0,0 +1,62 @@
1
+ from tlaplus_cli import config
2
+ from tlaplus_cli.cli import app
3
+
4
+
5
+ def test_modules_path_success(mocker, tmp_path, runner):
6
+ """Test successful setting of modules path."""
7
+ config.load_config.cache_clear()
8
+ config_dir = tmp_path / "config"
9
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
10
+
11
+ # Create a dummy module directory
12
+ module_dir = tmp_path / "my_modules"
13
+ module_dir.mkdir()
14
+
15
+ result = runner.invoke(app, ["modules", "path", str(module_dir)])
16
+
17
+ assert result.exit_code == 0
18
+ assert f"Modules path updated to: {module_dir}" in result.output
19
+
20
+ # Check config
21
+ config.load_config.cache_clear()
22
+ cfg = config.load_config()
23
+ assert cfg.module_path == str(module_dir)
24
+
25
+
26
+ def test_modules_path_invalid_dir(mocker, tmp_path, runner):
27
+ """Test setting modules path to non-existent directory fails."""
28
+ config.load_config.cache_clear()
29
+ config_dir = tmp_path / "config"
30
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
31
+
32
+ invalid_dir = tmp_path / "does_not_exist"
33
+
34
+ result = runner.invoke(app, ["modules", "path", str(invalid_dir)])
35
+
36
+ assert result.exit_code == 1
37
+ assert "Error: Path does not exist or is not a directory" in result.output
38
+
39
+ # Check config was NOT updated
40
+ config.load_config.cache_clear()
41
+ cfg = config.load_config()
42
+ assert cfg.module_path is None
43
+
44
+
45
+ def test_modules_path_file_instead_of_dir(mocker, tmp_path, runner):
46
+ """Test setting modules path to a file fails."""
47
+ config.load_config.cache_clear()
48
+ config_dir = tmp_path / "config"
49
+ mocker.patch("tlaplus_cli.config.config_dir", return_value=config_dir)
50
+
51
+ some_file = tmp_path / "some_file.txt"
52
+ some_file.write_text("hello")
53
+
54
+ result = runner.invoke(app, ["modules", "path", str(some_file)])
55
+
56
+ assert result.exit_code == 1
57
+ assert "Error: Path does not exist or is not a directory" in result.output
58
+
59
+ # Check config was NOT updated
60
+ config.load_config.cache_clear()
61
+ cfg = config.load_config()
62
+ assert cfg.module_path is None