tlaplus-cli 0.3.1__tar.gz → 0.3.3__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 (35) hide show
  1. {tlaplus_cli-0.3.1/src/tlaplus_cli.egg-info → tlaplus_cli-0.3.3}/PKG-INFO +33 -9
  2. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/README.md +32 -8
  3. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/pyproject.toml +4 -2
  4. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/build_tlc_module.py +13 -14
  5. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/cli.py +5 -2
  6. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/config.py +9 -5
  7. tlaplus_cli-0.3.3/src/tlaplus_cli/project.py +31 -0
  8. tlaplus_cli-0.3.3/src/tlaplus_cli/resources/__init__.py +1 -0
  9. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/run_tlc.py +43 -18
  10. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/tools_manager.py +5 -8
  11. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3/src/tlaplus_cli.egg-info}/PKG-INFO +33 -9
  12. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli.egg-info/SOURCES.txt +4 -0
  13. tlaplus_cli-0.3.3/tests/test_build_tlc_module.py +161 -0
  14. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_java_module_naming.py +6 -5
  15. tlaplus_cli-0.3.3/tests/test_project.py +50 -0
  16. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_run_tlc.py +8 -8
  17. tlaplus_cli-0.3.3/tests/test_spec_resolution.py +200 -0
  18. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_tools_manager.py +15 -10
  19. tlaplus_cli-0.3.1/tests/test_build_tlc_module.py +0 -77
  20. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/LICENSE +0 -0
  21. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/MANIFEST.in +0 -0
  22. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/setup.cfg +0 -0
  23. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/__init__.py +0 -0
  24. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/check_java.py +0 -0
  25. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/py.typed +0 -0
  26. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/resources/default_config.yaml +0 -0
  27. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/settings.py +0 -0
  28. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli/version_manager.py +0 -0
  29. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli.egg-info/dependency_links.txt +0 -0
  30. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli.egg-info/entry_points.txt +0 -0
  31. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli.egg-info/requires.txt +0 -0
  32. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/src/tlaplus_cli.egg-info/top_level.txt +0 -0
  33. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_check_java.py +0 -0
  34. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_cli.py +0 -0
  35. {tlaplus_cli-0.3.1 → tlaplus_cli-0.3.3}/tests/test_version_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -86,7 +86,7 @@ tla tools upgrade
86
86
  > [!NOTE]
87
87
  > If the target version to upgrade is not yet installed locally, the CLI will automatically download it.
88
88
 
89
- Show the TLC2 version string and absolute path to the pinned version's `tla2tools.jar`:
89
+ Show the absolute path to the pinned version's `tla2tools.jar`:
90
90
  ```bash
91
91
  tla tools path
92
92
  ```
@@ -98,7 +98,6 @@ tla tools path v1.8.0
98
98
 
99
99
  Example output:
100
100
  ```text
101
- TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)
102
101
  /home/bob/.cache/tla/tools/v1.8.0-5a47802/tla2tools.jar
103
102
  ```
104
103
 
@@ -139,23 +138,48 @@ For example (runs `queue.tla`):
139
138
  tla tlc queue
140
139
  ```
141
140
 
142
- > **Note:** Starting from version `0.3.0`, the command structure has changed. To run a model, use `tla tlc <spec>`. For version `0.2.x`, the command was `tla run <spec>`. Since `0.3.0`, management commands are under `tla tools`.
141
+ #### Project-Aware Execution
142
+
143
+ The CLI intelligently resolves the specification file and its project structure. It will check the following locations:
144
+ 1. `<spec_name>` (if it's a direct path to a file, e.g., `models/my_model.tla`)
145
+ 2. `<spec_name>.tla`
146
+ 3. `spec/<spec_name>.tla` (checks an inner `spec/` folder relative to the spec's location)
147
+
148
+ Furthermore, it automatically discovers the **project root** by looking for `modules/`, `classes/`, or `lib/` directories adjacent to the spec file or its parent. When a project root is found:
149
+ - The project's `classes/` directory is added to the Java classpath.
150
+ - Any `*.jar` files found in the project's `lib/` directory are added to the Java classpath.
151
+ - The `-DTLA-Library` system property is set to the project's `modules/` directory, allowing TLC to find your custom Java overrides.
152
+
153
+ To check the currently pinned `tla2tools.jar` path and its TLC version:
154
+
155
+ ```bash
156
+ tla tlc --version
157
+ ```
143
158
 
144
159
  ### Compile Custom Java Modules
145
160
 
146
- Note that modules are compiled using the pinned version of the toolset.
161
+ Java modules (overrides) are compiled using the pinned version of the toolset.
162
+
163
+ Compile modules in the current project:
164
+ ```bash
165
+ tla modules build
166
+ ```
147
167
 
148
- Compile modules:
168
+ Compile modules in a specific project path:
149
169
  ```bash
150
- tla build
170
+ tla modules build path/to/project
151
171
  ```
152
172
 
153
173
  Verbose output:
154
174
  ```bash
155
- tla build --verbose
175
+ tla modules build --verbose
156
176
  ```
157
177
 
158
- Compiles `.java` files from `workspace/modules/` into `workspace/classes/`.
178
+ The build command:
179
+ 1. Resolves the project root (defaults to `workspace.root` from config).
180
+ 2. Includes `lib/*.jar` files from the project root in the `javac` classpath.
181
+ 3. Compiles `.java` files from the project's `modules/` directory into its `classes/` directory.
182
+ 4. Generates the necessary Java service provider configuration for TLC overrides.
159
183
 
160
184
  ### Check Java Version
161
185
 
@@ -57,7 +57,7 @@ tla tools upgrade
57
57
  > [!NOTE]
58
58
  > If the target version to upgrade is not yet installed locally, the CLI will automatically download it.
59
59
 
60
- Show the TLC2 version string and absolute path to the pinned version's `tla2tools.jar`:
60
+ Show the absolute path to the pinned version's `tla2tools.jar`:
61
61
  ```bash
62
62
  tla tools path
63
63
  ```
@@ -69,7 +69,6 @@ tla tools path v1.8.0
69
69
 
70
70
  Example output:
71
71
  ```text
72
- TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)
73
72
  /home/bob/.cache/tla/tools/v1.8.0-5a47802/tla2tools.jar
74
73
  ```
75
74
 
@@ -110,23 +109,48 @@ For example (runs `queue.tla`):
110
109
  tla tlc queue
111
110
  ```
112
111
 
113
- > **Note:** Starting from version `0.3.0`, the command structure has changed. To run a model, use `tla tlc <spec>`. For version `0.2.x`, the command was `tla run <spec>`. Since `0.3.0`, management commands are under `tla tools`.
112
+ #### Project-Aware Execution
113
+
114
+ The CLI intelligently resolves the specification file and its project structure. It will check the following locations:
115
+ 1. `<spec_name>` (if it's a direct path to a file, e.g., `models/my_model.tla`)
116
+ 2. `<spec_name>.tla`
117
+ 3. `spec/<spec_name>.tla` (checks an inner `spec/` folder relative to the spec's location)
118
+
119
+ Furthermore, it automatically discovers the **project root** by looking for `modules/`, `classes/`, or `lib/` directories adjacent to the spec file or its parent. When a project root is found:
120
+ - The project's `classes/` directory is added to the Java classpath.
121
+ - Any `*.jar` files found in the project's `lib/` directory are added to the Java classpath.
122
+ - The `-DTLA-Library` system property is set to the project's `modules/` directory, allowing TLC to find your custom Java overrides.
123
+
124
+ To check the currently pinned `tla2tools.jar` path and its TLC version:
125
+
126
+ ```bash
127
+ tla tlc --version
128
+ ```
114
129
 
115
130
  ### Compile Custom Java Modules
116
131
 
117
- Note that modules are compiled using the pinned version of the toolset.
132
+ Java modules (overrides) are compiled using the pinned version of the toolset.
133
+
134
+ Compile modules in the current project:
135
+ ```bash
136
+ tla modules build
137
+ ```
118
138
 
119
- Compile modules:
139
+ Compile modules in a specific project path:
120
140
  ```bash
121
- tla build
141
+ tla modules build path/to/project
122
142
  ```
123
143
 
124
144
  Verbose output:
125
145
  ```bash
126
- tla build --verbose
146
+ tla modules build --verbose
127
147
  ```
128
148
 
129
- Compiles `.java` files from `workspace/modules/` into `workspace/classes/`.
149
+ The build command:
150
+ 1. Resolves the project root (defaults to `workspace.root` from config).
151
+ 2. Includes `lib/*.jar` files from the project root in the `javac` classpath.
152
+ 3. Compiles `.java` files from the project's `modules/` directory into its `classes/` directory.
153
+ 4. Generates the necessary Java service provider configuration for TLC overrides.
130
154
 
131
155
  ### Check Java Version
132
156
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tlaplus-cli"
7
- version = "0.3.1"
7
+ version = "0.3.3"
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 = ["resources/default_config.yaml", "py.typed"]
48
+ tlaplus_cli = ["py.typed"]
49
+ "tlaplus_cli.resources" = ["*.yaml"]
49
50
 
50
51
  [dependency-groups]
51
52
  dev = [
@@ -101,6 +102,7 @@ ignore = [
101
102
  [tool.ruff.lint.per-file-ignores]
102
103
  "tests/*" = [
103
104
  "ARG001", # Unused arguments are common in tests (fixtures)
105
+ "PLR0913", # Many arguments are common in tests (fixtures)
104
106
  ]
105
107
 
106
108
  [tool.poe.tasks]
@@ -1,6 +1,8 @@
1
1
  """Compile custom TLC modules (Java)."""
2
2
 
3
+ import os
3
4
  import subprocess
5
+ from pathlib import Path
4
6
 
5
7
  import typer
6
8
 
@@ -9,10 +11,12 @@ from tlaplus_cli.version_manager import get_pinned_version_dir
9
11
 
10
12
 
11
13
  def build(
14
+ path: str | None = typer.Argument(None, help="Project root directory (defaults to workspace root)."),
12
15
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show compilation output."),
13
16
  ) -> None:
14
17
  """Compile custom Java modules."""
15
18
  config = load_config()
19
+ base_dir = Path(path).resolve() if path is not None else workspace_root()
16
20
 
17
21
  # Fallback chain: pinned directory -> legacy jar
18
22
  pinned_dir = get_pinned_version_dir()
@@ -20,31 +24,29 @@ def build(
20
24
  legacy = cache_dir() / "tla2tools.jar"
21
25
  jar_path = pinned_jar if (pinned_jar and pinned_jar.exists()) else legacy
22
26
 
23
- ws_root = workspace_root()
24
- modules_dir = ws_root / config.workspace.modules_dir
25
- classes_dir = ws_root / config.workspace.classes_dir
26
-
27
27
  if not jar_path.exists():
28
- typer.echo("Error: tla2tools.jar not found.", err=True)
29
- typer.echo("Run 'tla tlc install' first.", err=True)
28
+ typer.echo("Error: tla2tools.jar not found.\nRun 'tla tools install' first.", err=True)
30
29
  raise typer.Exit(1)
31
30
 
31
+ modules_dir = base_dir / config.workspace.modules_dir
32
+ classes_dir = base_dir / config.workspace.classes_dir
33
+
34
+ lib_dir = base_dir / "lib"
35
+ lib_jars = sorted(lib_dir.glob("*.jar")) if lib_dir.is_dir() else []
36
+ classpath = os.pathsep.join([str(jar_path)] + [str(j) for j in lib_jars])
37
+
32
38
  if not modules_dir.exists():
33
39
  typer.echo(f"Error: modules directory not found: {modules_dir}", err=True)
34
40
  raise typer.Exit(1)
35
41
 
36
- # Find all .java files in modules dir
37
42
  java_files = list(modules_dir.rglob("*.java"))
38
43
  if not java_files:
39
44
  typer.echo(f"No Java source files found in {modules_dir}", err=True)
40
45
  return
41
46
 
42
47
  typer.echo(f"Compiling {len(java_files)} Java files from {modules_dir} ...")
43
-
44
- # Ensure output dir exists
45
48
  classes_dir.mkdir(parents=True, exist_ok=True)
46
-
47
- cmd = ["javac", "-cp", str(jar_path), "-d", str(classes_dir), *[str(f) for f in java_files]]
49
+ cmd = ["javac", "-cp", classpath, "-d", str(classes_dir), *[str(f) for f in java_files]]
48
50
 
49
51
  try:
50
52
  result = subprocess.run(cmd, check=True, capture_output=not verbose, text=True)
@@ -52,14 +54,11 @@ def build(
52
54
  if verbose and result.stdout:
53
55
  typer.echo(result.stdout)
54
56
 
55
- # Create META-INF/services/tlc2.overrides.ITLCOverrides
56
57
  meta_inf = classes_dir / "META-INF" / "services"
57
58
  meta_inf.mkdir(parents=True, exist_ok=True)
58
59
  service_file = meta_inf / "tlc2.overrides.ITLCOverrides"
59
-
60
60
  with service_file.open("w") as f:
61
61
  f.write(f"{config.tlc.overrides_class}\n")
62
-
63
62
  typer.echo(f"Created service file at {service_file}")
64
63
 
65
64
  except subprocess.CalledProcessError as e:
@@ -47,12 +47,15 @@ def root(
47
47
 
48
48
  # --- Subcommands ---
49
49
 
50
- # Attach typer subgroups for tools and fetch-cache
50
+ # Attach typer subgroups
51
+ modules_app = typer.Typer(name="modules", help="Manage TLA+ Java modules.", no_args_is_help=True)
52
+ modules_app.command(name="build")(build_tlc_cmd)
53
+
54
+ app.add_typer(modules_app, name="modules")
51
55
  app.add_typer(tools_app, name="tools")
52
56
  app.add_typer(fetch_cache_app, name="fetch-cache")
53
57
 
54
58
  app.command(name="tlc")(run_tlc_cmd)
55
- app.command(name="build")(build_tlc_cmd)
56
59
 
57
60
 
58
61
  @app.command(name="check-java")
@@ -1,4 +1,4 @@
1
- import shutil
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 _default_config_path() -> Path:
41
- """Path to the default config shipped with the package."""
42
- return Path(__file__).parent / "resources" / "default_config.yaml"
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
- shutil.copy2(_default_config_path(), cp)
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
 
@@ -0,0 +1,31 @@
1
+ """Project root discovery utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def _is_project_root(directory: Path, modules_dir: Path | str, classes_dir: Path | str, lib_dir: Path | str) -> bool:
7
+ """Return True if directory contains at least one project structure marker."""
8
+ return (directory / classes_dir).is_dir() or (directory / modules_dir).is_dir() or (directory / lib_dir).is_dir()
9
+
10
+
11
+ def find_project_root(
12
+ spec_file: Path,
13
+ *,
14
+ modules_dir: Path | str,
15
+ classes_dir: Path | str,
16
+ lib_dir: Path | str = "lib",
17
+ ) -> Path | None:
18
+ """Resolve the project root relative to a .tla spec file.
19
+
20
+ Resolution strategy (in order):
21
+ 1. Check the spec file's own directory.
22
+ 2. Check the parent of the spec file's directory (handles spec/ and tests/ subdirs).
23
+ 3. Return None if neither directory qualifies.
24
+ """
25
+ spec_dir = spec_file.parent.resolve()
26
+
27
+ for candidate in (spec_dir, spec_dir.parent):
28
+ if _is_project_root(candidate, modules_dir, classes_dir, lib_dir):
29
+ return candidate
30
+
31
+ return None
@@ -0,0 +1 @@
1
+ """Resources subpackage."""
@@ -2,11 +2,13 @@
2
2
 
3
3
  import os
4
4
  import subprocess
5
+ from pathlib import Path
5
6
 
6
7
  import typer
7
8
 
8
9
  from tlaplus_cli.check_java import check_java_version
9
- from tlaplus_cli.config import cache_dir, load_config, workspace_root
10
+ from tlaplus_cli.config import cache_dir, load_config
11
+ from tlaplus_cli.project import find_project_root
10
12
  from tlaplus_cli.version_manager import get_pinned_version_dir
11
13
 
12
14
 
@@ -62,25 +64,48 @@ def tlc(
62
64
  jar_path = pinned_jar if (pinned_jar and pinned_jar.exists()) else legacy
63
65
 
64
66
  if not jar_path.exists():
65
- typer.echo("Error: tla2tools.jar not found.", err=True)
66
- typer.echo("Run 'tla tools install' first.", err=True)
67
+ typer.echo("Error: tla2tools.jar not found.\nRun 'tla tools install' first.", err=True)
67
68
  raise typer.Exit(1)
68
69
 
69
- ws_root = workspace_root()
70
- spec_dir = ws_root / config.workspace.spec_dir
71
- spec_file = spec_dir / f"{spec}.tla"
72
- if not spec_file.exists():
73
- typer.echo(f"Error: specification not found: {spec_file}", err=True)
74
- raise typer.Exit(1)
75
-
76
- classpath = str(jar_path)
77
- classes_path = ws_root / config.workspace.classes_dir
78
- if classes_path.exists():
79
- classpath = f"{classes_path}{os.pathsep}{classpath}"
70
+ # Spec resolution logic
71
+ spec_path = Path(spec)
72
+ candidates = [spec_path, spec_path.with_suffix(".tla"), spec_path.parent / "spec" / (spec_path.name + ".tla")]
80
73
 
81
- cmd = ["java", *config.java.opts, "-cp", classpath, config.tlc.java_class, str(spec_file)]
74
+ spec_file = next((c for c in candidates if c.is_file()), None)
75
+ if not spec_file:
76
+ typer.echo("Error: Could not find a TLA+ spec file. Looked in the following locations:", err=True)
77
+ for c in candidates:
78
+ typer.echo(f"- {c}", err=True)
79
+ raise typer.Exit(1)
82
80
 
83
- typer.echo(f"Running TLC on {spec}.tla ...")
84
- typer.echo(f"Command: {' '.join(cmd)}")
85
- result = subprocess.run(cmd, cwd=str(spec_dir))
81
+ spec_file = spec_file.absolute()
82
+ project_root = find_project_root(
83
+ spec_file, modules_dir=config.workspace.modules_dir, classes_dir=config.workspace.classes_dir
84
+ )
85
+
86
+ classpath_parts = [str(jar_path)]
87
+ extra_jvm_opts: list[str] = []
88
+ if project_root:
89
+ classes_path = project_root / config.workspace.classes_dir
90
+ if classes_path.is_dir():
91
+ classpath_parts.insert(0, str(classes_path))
92
+ lib_dir = project_root / "lib"
93
+ if lib_dir.is_dir():
94
+ classpath_parts.extend(str(j) for j in sorted(lib_dir.glob("*.jar")))
95
+ modules_path = project_root / config.workspace.modules_dir
96
+ if modules_path.is_dir():
97
+ extra_jvm_opts.append(f"-DTLA-Library={modules_path}")
98
+
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
+ ]
108
+
109
+ typer.echo(f"Running TLC on {spec_file.name} ...\nCommand: {' '.join(cmd)}")
110
+ result = subprocess.run(cmd, cwd=str(spec_file.parent))
86
111
  raise typer.Exit(result.returncode)
@@ -205,7 +205,7 @@ def path(version: str = typer.Argument(None)) -> None:
205
205
  if pinned_dir:
206
206
  jar = pinned_dir / "tla2tools.jar"
207
207
  if jar.exists():
208
- _print_version_path(pinned_dir, jar)
208
+ _print_version_path(jar)
209
209
  return
210
210
  typer.echo("Error: No pinned version found.", err=True)
211
211
  raise typer.Exit(1)
@@ -213,18 +213,15 @@ def path(version: str = typer.Argument(None)) -> None:
213
213
  for lv in list_local_versions():
214
214
  if lv.name == version:
215
215
  jar = lv.path / "tla2tools.jar"
216
- _print_version_path(lv.path, jar)
216
+ _print_version_path(jar)
217
217
  return
218
218
  typer.echo(f"Error: Version {version} not found locally.", err=True)
219
219
  raise typer.Exit(1)
220
220
 
221
221
 
222
- def _print_version_path(version_dir: Path, jar_path: Path) -> None:
223
- """Print the TLC2 version string (if available) and jar path."""
224
- meta = read_version_metadata(version_dir)
225
- if meta and meta.get("tlc2_version_string"):
226
- typer.echo(meta["tlc2_version_string"])
227
- typer.echo(str(jar_path))
222
+ def _print_version_path(jar_path: Path) -> None:
223
+ """Print the absolute path to tla2tools.jar."""
224
+ typer.echo(str(jar_path.absolute()))
228
225
 
229
226
 
230
227
  @tools_app.command()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -86,7 +86,7 @@ tla tools upgrade
86
86
  > [!NOTE]
87
87
  > If the target version to upgrade is not yet installed locally, the CLI will automatically download it.
88
88
 
89
- Show the TLC2 version string and absolute path to the pinned version's `tla2tools.jar`:
89
+ Show the absolute path to the pinned version's `tla2tools.jar`:
90
90
  ```bash
91
91
  tla tools path
92
92
  ```
@@ -98,7 +98,6 @@ tla tools path v1.8.0
98
98
 
99
99
  Example output:
100
100
  ```text
101
- TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)
102
101
  /home/bob/.cache/tla/tools/v1.8.0-5a47802/tla2tools.jar
103
102
  ```
104
103
 
@@ -139,23 +138,48 @@ For example (runs `queue.tla`):
139
138
  tla tlc queue
140
139
  ```
141
140
 
142
- > **Note:** Starting from version `0.3.0`, the command structure has changed. To run a model, use `tla tlc <spec>`. For version `0.2.x`, the command was `tla run <spec>`. Since `0.3.0`, management commands are under `tla tools`.
141
+ #### Project-Aware Execution
142
+
143
+ The CLI intelligently resolves the specification file and its project structure. It will check the following locations:
144
+ 1. `<spec_name>` (if it's a direct path to a file, e.g., `models/my_model.tla`)
145
+ 2. `<spec_name>.tla`
146
+ 3. `spec/<spec_name>.tla` (checks an inner `spec/` folder relative to the spec's location)
147
+
148
+ Furthermore, it automatically discovers the **project root** by looking for `modules/`, `classes/`, or `lib/` directories adjacent to the spec file or its parent. When a project root is found:
149
+ - The project's `classes/` directory is added to the Java classpath.
150
+ - Any `*.jar` files found in the project's `lib/` directory are added to the Java classpath.
151
+ - The `-DTLA-Library` system property is set to the project's `modules/` directory, allowing TLC to find your custom Java overrides.
152
+
153
+ To check the currently pinned `tla2tools.jar` path and its TLC version:
154
+
155
+ ```bash
156
+ tla tlc --version
157
+ ```
143
158
 
144
159
  ### Compile Custom Java Modules
145
160
 
146
- Note that modules are compiled using the pinned version of the toolset.
161
+ Java modules (overrides) are compiled using the pinned version of the toolset.
162
+
163
+ Compile modules in the current project:
164
+ ```bash
165
+ tla modules build
166
+ ```
147
167
 
148
- Compile modules:
168
+ Compile modules in a specific project path:
149
169
  ```bash
150
- tla build
170
+ tla modules build path/to/project
151
171
  ```
152
172
 
153
173
  Verbose output:
154
174
  ```bash
155
- tla build --verbose
175
+ tla modules build --verbose
156
176
  ```
157
177
 
158
- Compiles `.java` files from `workspace/modules/` into `workspace/classes/`.
178
+ The build command:
179
+ 1. Resolves the project root (defaults to `workspace.root` from config).
180
+ 2. Includes `lib/*.jar` files from the project root in the `javac` classpath.
181
+ 3. Compiles `.java` files from the project's `modules/` directory into its `classes/` directory.
182
+ 4. Generates the necessary Java service provider configuration for TLC overrides.
159
183
 
160
184
  ### Check Java Version
161
185
 
@@ -7,6 +7,7 @@ src/tlaplus_cli/build_tlc_module.py
7
7
  src/tlaplus_cli/check_java.py
8
8
  src/tlaplus_cli/cli.py
9
9
  src/tlaplus_cli/config.py
10
+ src/tlaplus_cli/project.py
10
11
  src/tlaplus_cli/py.typed
11
12
  src/tlaplus_cli/run_tlc.py
12
13
  src/tlaplus_cli/settings.py
@@ -18,11 +19,14 @@ src/tlaplus_cli.egg-info/dependency_links.txt
18
19
  src/tlaplus_cli.egg-info/entry_points.txt
19
20
  src/tlaplus_cli.egg-info/requires.txt
20
21
  src/tlaplus_cli.egg-info/top_level.txt
22
+ src/tlaplus_cli/resources/__init__.py
21
23
  src/tlaplus_cli/resources/default_config.yaml
22
24
  tests/test_build_tlc_module.py
23
25
  tests/test_check_java.py
24
26
  tests/test_cli.py
25
27
  tests/test_java_module_naming.py
28
+ tests/test_project.py
26
29
  tests/test_run_tlc.py
30
+ tests/test_spec_resolution.py
27
31
  tests/test_tools_manager.py
28
32
  tests/test_version_manager.py
@@ -0,0 +1,161 @@
1
+ import shutil
2
+
3
+ import pytest
4
+ from typer.testing import CliRunner
5
+
6
+ from tlaplus_cli.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+ # Define paths relative to the test file
11
+ # Check if javac is available
12
+ JAVAC_AVAILABLE = shutil.which("javac") is not None
13
+
14
+
15
+ @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
16
+ def test_build_integration(mocker, tmp_path, queue_dir, base_settings):
17
+ """
18
+ Integration test for build_tlc_module.build().
19
+ Uses the actual files in tla-example and the real tla2tools.jar (if available).
20
+ Verified output classes are generated in a temporary directory.
21
+ """
22
+ # Create a temporary classes directory
23
+ classes_dir = tmp_path / "classes"
24
+
25
+ # Configure base_settings for this test
26
+ base_settings.workspace.root = queue_dir
27
+ base_settings.workspace.classes_dir = classes_dir
28
+
29
+ # Patch load_config to return our mock config
30
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
31
+
32
+ # Patch workspace_root to return the example directory
33
+ mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=queue_dir)
34
+
35
+ # Run build
36
+ result = runner.invoke(app, ["modules", "build", "--verbose"])
37
+ assert result.exit_code == 0, f"Build failed with {result.exit_code}: {result.stdout}"
38
+
39
+ # Verify outputs
40
+ assert classes_dir.exists(), "Classes directory not created"
41
+
42
+ # Verify TLCOverrides.class exists
43
+ # The source is modules/tlc2/overrides/TLCOverrides.java
44
+ # Package is tlc2.overrides, so output should be in tlc2/overrides/
45
+ output_class = classes_dir / "tlc2/overrides/TLCOverrides.class"
46
+ assert output_class.exists(), f"Class file not found at {output_class}"
47
+
48
+ # Verify META-INF service file
49
+ service_file = classes_dir / "META-INF/services/tlc2.overrides.ITLCOverrides"
50
+ assert service_file.exists(), "Service file not found"
51
+
52
+ content = service_file.read_text().strip()
53
+ assert content == "tlc2.overrides.TLCOverrides", f"Unexpected service file content: {content}"
54
+
55
+
56
+ @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
57
+ def test_build_custom_overrides(mocker, tmp_path, queue_dir, base_settings):
58
+ """
59
+ Test build with custom overrides class configuration.
60
+ """
61
+ classes_dir = tmp_path / "classes"
62
+
63
+ # Configure custom override
64
+ custom_override = "com.example.MyOverrides"
65
+ base_settings.tlc.overrides_class = custom_override
66
+ base_settings.workspace.root = queue_dir
67
+ base_settings.workspace.classes_dir = classes_dir
68
+
69
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
70
+ mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=queue_dir)
71
+
72
+ result = runner.invoke(app, ["modules", "build"])
73
+ assert result.exit_code == 0, f"Build failed: {result.stdout}"
74
+
75
+ service_file = classes_dir / "META-INF/services/tlc2.overrides.ITLCOverrides"
76
+ assert service_file.exists()
77
+ assert service_file.read_text().strip() == custom_override
78
+
79
+
80
+ def test_build_with_explicit_path(mocker, tmp_path, base_settings):
81
+ """modules build with explicit path uses that path as project root."""
82
+ project_dir = tmp_path / "my_project"
83
+ modules_dir = project_dir / "modules"
84
+ modules_dir.mkdir(parents=True)
85
+ (modules_dir / "Foo.java").write_text("class Foo {}")
86
+
87
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
88
+ mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=tmp_path)
89
+
90
+ mock_run = mocker.patch("tlaplus_cli.build_tlc_module.subprocess.run")
91
+ mock_run.return_value.returncode = 0
92
+
93
+ # Patch jar path to exist
94
+ pinned_dir = tmp_path / "tools" / "v1.8.0"
95
+ pinned_dir.mkdir(parents=True)
96
+ (pinned_dir / "tla2tools.jar").write_bytes(b"fake")
97
+ mocker.patch("tlaplus_cli.build_tlc_module.get_pinned_version_dir", return_value=pinned_dir)
98
+
99
+ result = runner.invoke(app, ["modules", "build", str(project_dir)])
100
+ assert result.exit_code == 0
101
+ args, _ = mock_run.call_args
102
+ cmd = args[0]
103
+ assert str(project_dir / "classes") in cmd # -d target
104
+ assert str(modules_dir / "Foo.java") in cmd # source file
105
+
106
+
107
+ def test_build_without_path_uses_workspace_root(mocker, tmp_path, base_settings):
108
+ """modules build without path uses workspace_root from config."""
109
+ project_dir = tmp_path / "workspace"
110
+ project_dir.mkdir()
111
+ modules_dir = project_dir / "modules"
112
+ modules_dir.mkdir()
113
+ (modules_dir / "Bar.java").write_text("class Bar {}")
114
+
115
+ base_settings.workspace.root = project_dir
116
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
117
+ mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=project_dir)
118
+
119
+ mock_run = mocker.patch("tlaplus_cli.build_tlc_module.subprocess.run")
120
+ mock_run.return_value.returncode = 0
121
+
122
+ # Patch jar path to exist
123
+ pinned_dir = tmp_path / "tools" / "v1.8.0"
124
+ pinned_dir.mkdir(parents=True)
125
+ (pinned_dir / "tla2tools.jar").write_bytes(b"fake")
126
+ mocker.patch("tlaplus_cli.build_tlc_module.get_pinned_version_dir", return_value=pinned_dir)
127
+
128
+ result = runner.invoke(app, ["modules", "build"])
129
+ assert result.exit_code == 0
130
+ args, _ = mock_run.call_args
131
+ cmd = args[0]
132
+ assert str(project_dir / "classes") in cmd
133
+ assert str(modules_dir / "Bar.java") in cmd
134
+
135
+
136
+ def test_build_includes_lib_jars_in_classpath(mocker, tmp_path, base_settings):
137
+ """javac classpath includes *.jar files from project lib/ directory."""
138
+ project_dir = tmp_path / "my_project"
139
+ (project_dir / "modules").mkdir(parents=True)
140
+ (project_dir / "modules" / "Foo.java").write_text("class Foo {}")
141
+ lib_dir = project_dir / "lib"
142
+ lib_dir.mkdir()
143
+ extra_jar = lib_dir / "extra.jar"
144
+ extra_jar.write_bytes(b"fake")
145
+
146
+ pinned_dir = tmp_path / "tools" / "v1.8.0"
147
+ pinned_dir.mkdir(parents=True)
148
+ tla_jar = pinned_dir / "tla2tools.jar"
149
+ tla_jar.write_bytes(b"fake")
150
+
151
+ mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
152
+ mocker.patch("tlaplus_cli.build_tlc_module.get_pinned_version_dir", return_value=pinned_dir)
153
+
154
+ mock_run = mocker.patch("tlaplus_cli.build_tlc_module.subprocess.run")
155
+ mock_run.return_value.returncode = 0
156
+
157
+ result = runner.invoke(app, ["modules", "build", str(project_dir)])
158
+ assert result.exit_code == 0
159
+ args, _ = mock_run.call_args
160
+ cmd_str = " ".join(args[0])
161
+ assert str(extra_jar) in cmd_str
@@ -37,21 +37,21 @@ def setup_naming_env(tmp_path, mocker, base_settings, fixture_dir):
37
37
  mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
38
38
  mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=fixture_dir)
39
39
  mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
40
- mocker.patch("tlaplus_cli.run_tlc.workspace_root", return_value=fixture_dir)
41
40
 
42
41
 
43
42
  @pytest.mark.skipif(not JAVA_AVAILABLE, reason="java not found")
44
43
  @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
45
- def test_tlc_overrides_naming_works(tmp_path, mocker, base_settings, capfd, naming_fixed_dir):
44
+ def test_tlc_overrides_naming_works(tmp_path, mocker, base_settings, capfd, naming_fixed_dir, monkeypatch):
46
45
  """
47
46
  Tests that naming the class 'TLCOverrides' in 'tlc2.overrides' package works.
48
47
  This is the currently supported approach.
49
48
  """
50
49
  setup_naming_env(tmp_path, mocker, base_settings, naming_fixed_dir)
51
50
 
52
- res_build = runner.invoke(app, ["build"])
51
+ res_build = runner.invoke(app, ["modules", "build"])
53
52
  assert res_build.exit_code == 0, f"Module compilation failed: {res_build.stdout}"
54
53
 
54
+ monkeypatch.chdir(naming_fixed_dir)
55
55
  res_tlc = runner.invoke(app, ["tlc", "test_spec"])
56
56
  assert res_tlc.exit_code == 0, f"TLC run failed: {res_tlc.stdout}"
57
57
 
@@ -61,7 +61,7 @@ def test_tlc_overrides_naming_works(tmp_path, mocker, base_settings, capfd, nami
61
61
 
62
62
  @pytest.mark.skipif(not JAVA_AVAILABLE, reason="java not found")
63
63
  @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
64
- def test_module_name_class_naming_fails(tmp_path, mocker, base_settings, capfd, naming_dynamic_dir):
64
+ def test_module_name_class_naming_fails(tmp_path, mocker, base_settings, capfd, naming_dynamic_dir, monkeypatch):
65
65
  """
66
66
  Tests that naming the class after the module ('TestModule') fails to load the override.
67
67
  According to current TLC behavior, this doesn't work. We want this test to pass
@@ -69,9 +69,10 @@ def test_module_name_class_naming_fails(tmp_path, mocker, base_settings, capfd,
69
69
  """
70
70
  setup_naming_env(tmp_path, mocker, base_settings, naming_dynamic_dir)
71
71
 
72
- res_build = runner.invoke(app, ["build"])
72
+ res_build = runner.invoke(app, ["modules", "build"])
73
73
  assert res_build.exit_code == 0, f"Module compilation failed: {res_build.stdout}"
74
74
 
75
+ monkeypatch.chdir(naming_dynamic_dir)
75
76
  res_tlc = runner.invoke(app, ["tlc", "test_spec"])
76
77
  assert res_tlc.exit_code == 0, f"TLC run failed: {res_tlc.stdout}"
77
78
 
@@ -0,0 +1,50 @@
1
+ from tlaplus_cli.project import find_project_root
2
+
3
+
4
+ def test_project_root_at_spec_dir(tmp_path):
5
+ """Root found at spec's own directory when it contains classes/."""
6
+ spec = tmp_path / "queue.tla"
7
+ spec.write_text("MODULE queue\n===\n")
8
+ (tmp_path / "classes").mkdir()
9
+ root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
10
+ assert root == tmp_path
11
+
12
+
13
+ def test_project_root_at_parent_of_spec_dir(tmp_path):
14
+ """Root found one level up when spec is inside a spec/ subdirectory."""
15
+ spec_subdir = tmp_path / "spec"
16
+ spec_subdir.mkdir()
17
+ spec = spec_subdir / "queue.tla"
18
+ spec.write_text("MODULE queue\n===\n")
19
+ (tmp_path / "classes").mkdir()
20
+ root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
21
+ assert root == tmp_path
22
+
23
+
24
+ def test_project_root_none_when_no_structure(tmp_path):
25
+ """Returns None if neither spec dir nor its parent contain project directories."""
26
+ spec = tmp_path / "spec" / "queue.tla"
27
+ spec.parent.mkdir()
28
+ spec.write_text("MODULE queue\n===\n")
29
+ root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
30
+ assert root is None
31
+
32
+
33
+ def test_project_root_detected_by_lib(tmp_path):
34
+ """Root found based on lib/ directory even without classes/."""
35
+ spec = tmp_path / "tests" / "queue_test.tla"
36
+ spec.parent.mkdir()
37
+ spec.write_text("MODULE queue_test\n===\n")
38
+ (tmp_path / "lib").mkdir()
39
+ root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
40
+ assert root == tmp_path
41
+
42
+
43
+ def test_project_root_detected_by_modules(tmp_path):
44
+ """Root found based on modules/ directory alone."""
45
+ spec = tmp_path / "spec" / "queue.tla"
46
+ spec.parent.mkdir()
47
+ spec.write_text("MODULE queue\n===\n")
48
+ (tmp_path / "modules").mkdir()
49
+ root = find_project_root(spec, modules_dir="modules", classes_dir="classes", lib_dir="lib")
50
+ assert root == tmp_path
@@ -27,7 +27,7 @@ def compiled_modules(tmp_path_factory):
27
27
 
28
28
  @pytest.mark.skipif(not JAVA_AVAILABLE, reason="java not found")
29
29
  @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
30
- def test_tlc_integration(mocker, tmp_path, capfd, queue_dir, base_settings):
30
+ def test_tlc_integration(mocker, tmp_path, capfd, queue_dir, base_settings, monkeypatch):
31
31
  """
32
32
  Integration test for run_tlc.tlc().
33
33
  1. Compiles the modules (prerequisite).
@@ -41,7 +41,6 @@ def test_tlc_integration(mocker, tmp_path, capfd, queue_dir, base_settings):
41
41
  base_settings.workspace.classes_dir = classes_dir
42
42
 
43
43
  mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
44
- mocker.patch("tlaplus_cli.run_tlc.workspace_root", return_value=queue_dir)
45
44
 
46
45
  # We also need to patch build_tlc_module's config loading to compile first
47
46
  mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
@@ -49,14 +48,14 @@ def test_tlc_integration(mocker, tmp_path, capfd, queue_dir, base_settings):
49
48
  mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=queue_dir)
50
49
 
51
50
  # 1. Compile modules
52
- res_build = runner.invoke(app, ["build"])
51
+ res_build = runner.invoke(app, ["modules", "build"])
53
52
  assert res_build.exit_code == 0, f"Module compilation failed: {res_build.stdout}"
54
-
55
53
  # 2. Run TLC
56
54
  # Verify that classes_dir is populated
57
55
  assert (classes_dir / "tlc2/overrides/TLCOverrides.class").exists()
58
56
 
59
57
  # Run TLC on "queue" spec
58
+ monkeypatch.chdir(queue_dir)
60
59
  res_tlc = runner.invoke(app, ["tlc", "queue"])
61
60
  assert res_tlc.exit_code == 0, f"TLC run failed: {res_tlc.stdout}"
62
61
 
@@ -73,17 +72,17 @@ def test_tlc_integration(mocker, tmp_path, capfd, queue_dir, base_settings):
73
72
 
74
73
 
75
74
  def test_tlc_version_flag(mocker, capfd, base_settings, tmp_path):
76
- """Test that 'tla tlc --version' prints the jar path and version."""
75
+ """Test that 'tla tlc --version' prints the jar path and only the first line of TLC version."""
77
76
  mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
78
77
 
79
78
  # Create a fake pinned version dir with a jar
80
- pinned_dir = tmp_path / "tools" / "v1.8.0-abcdef1"
79
+ pinned_dir = (tmp_path / "tools" / "v1.8.0-abcdef1").absolute()
81
80
  pinned_dir.mkdir(parents=True)
82
81
  (pinned_dir / "tla2tools.jar").write_bytes(b"fake jar")
83
82
  mocker.patch("tlaplus_cli.run_tlc.get_pinned_version_dir", return_value=pinned_dir)
84
83
 
85
84
  mock_result = mocker.MagicMock()
86
- mock_result.stdout = "TLC2 Version Mock\\nSome other output"
85
+ mock_result.stdout = "TLC2 Version Mock\nSome other output"
87
86
  mock_result.stderr = ""
88
87
  mocker.patch("tlaplus_cli.run_tlc.subprocess.run", return_value=mock_result)
89
88
 
@@ -92,5 +91,6 @@ def test_tlc_version_flag(mocker, capfd, base_settings, tmp_path):
92
91
 
93
92
  captured = capfd.readouterr()
94
93
  stdout = captured.out + result.stdout
95
- assert "tla2tools.jar path:" in stdout
94
+ assert f"tla2tools.jar path: {pinned_dir / 'tla2tools.jar'}" in stdout
96
95
  assert "TLC2 Version Mock" in stdout
96
+ assert "Some other output" not in stdout
@@ -0,0 +1,200 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from typer.testing import CliRunner
5
+
6
+ from tlaplus_cli.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_tlc_env(mocker, tmp_path, base_settings):
13
+ mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
14
+ mocker.patch("tlaplus_cli.run_tlc.check_java_version")
15
+
16
+ pinned_dir = (tmp_path / "tools" / "v1.8.0").absolute()
17
+ pinned_dir.mkdir(parents=True)
18
+ (pinned_dir / "tla2tools.jar").write_bytes(b"fake")
19
+ mocker.patch("tlaplus_cli.run_tlc.get_pinned_version_dir", return_value=pinned_dir)
20
+
21
+ mock_run = mocker.patch("tlaplus_cli.run_tlc.subprocess.run")
22
+ mock_run.return_value.returncode = 0
23
+ return mock_run
24
+
25
+
26
+ def test_scenario_1_explicit_extension(mock_tlc_env, tmp_path):
27
+ """Scenario 1: Model with explicit .tla extension in CWD."""
28
+ spec_name = "my_model.tla"
29
+ with runner.isolated_filesystem(temp_dir=tmp_path):
30
+ Path(spec_name).write_text("MODULE my_model\n===\n")
31
+ result = runner.invoke(app, ["tlc", spec_name])
32
+
33
+ assert result.exit_code == 0
34
+ args, _ = mock_tlc_env.call_args
35
+ assert "my_model.tla" in args[0]
36
+
37
+
38
+ def test_scenario_2_missing_extension_cwd(mock_tlc_env, tmp_path):
39
+ """Scenario 2: Model with missing .tla extension in CWD."""
40
+ spec_name = "my_model"
41
+ with runner.isolated_filesystem(temp_dir=tmp_path):
42
+ Path(f"{spec_name}.tla").write_text("MODULE my_model\n===\n")
43
+ result = runner.invoke(app, ["tlc", spec_name])
44
+
45
+ assert result.exit_code == 0
46
+ args, _ = mock_tlc_env.call_args
47
+ assert "my_model.tla" in args[0]
48
+
49
+
50
+ def test_scenario_3_inside_spec_directory_cwd(mock_tlc_env, tmp_path):
51
+ """Scenario 3: Model inside a spec/ subdirectory in CWD."""
52
+ spec_name = "my_model"
53
+ with runner.isolated_filesystem(temp_dir=tmp_path):
54
+ Path("spec").mkdir()
55
+ Path("spec/my_model.tla").write_text("MODULE my_model\n===\n")
56
+ result = runner.invoke(app, ["tlc", spec_name])
57
+
58
+ assert result.exit_code == 0
59
+ args, _ = mock_tlc_env.call_args
60
+ assert "my_model.tla" in args[0]
61
+
62
+
63
+ def test_scenario_4_spec_not_found_cwd(mock_tlc_env, tmp_path):
64
+ """Scenario 4: Spec not found in CWD (validate multiline error)."""
65
+ spec_name = "missing_model"
66
+ with runner.isolated_filesystem(temp_dir=tmp_path):
67
+ result = runner.invoke(app, ["tlc", spec_name])
68
+
69
+ assert result.exit_code == 1
70
+ assert "Error: Could not find a TLA+ spec file. Looked in the following locations:" in result.output
71
+ assert f"- {spec_name}" in result.output
72
+ assert f"- {spec_name}.tla" in result.output
73
+ assert f"- spec/{spec_name}.tla" in result.output
74
+
75
+
76
+ def test_scenario_5_missing_extension_path(mock_tlc_env, tmp_path):
77
+ """Scenario 5: Spec missing .tla extension, but inside a specified path."""
78
+ spec_path = "subdir/my_model"
79
+ with runner.isolated_filesystem(temp_dir=tmp_path):
80
+ Path("subdir").mkdir()
81
+ Path("subdir/my_model.tla").write_text("MODULE my_model\n===\n")
82
+ result = runner.invoke(app, ["tlc", spec_path])
83
+
84
+ assert result.exit_code == 0
85
+ args, _ = mock_tlc_env.call_args
86
+ assert "my_model.tla" in args[0]
87
+
88
+
89
+ def test_scenario_6_inside_spec_directory_path(mock_tlc_env, tmp_path):
90
+ """Scenario 6: Spec inside a spec/ subdirectory within a specified path."""
91
+ spec_path = "subdir/my_model"
92
+ with runner.isolated_filesystem(temp_dir=tmp_path):
93
+ Path("subdir/spec").mkdir(parents=True)
94
+ Path("subdir/spec/my_model.tla").write_text("MODULE my_model\n===\n")
95
+ result = runner.invoke(app, ["tlc", spec_path])
96
+
97
+ assert result.exit_code == 0
98
+ args, _ = mock_tlc_env.call_args
99
+ assert "my_model.tla" in args[0]
100
+
101
+
102
+ def test_scenario_7_spec_not_found_path(mock_tlc_env, tmp_path):
103
+ """Scenario 7: Spec not found with a path (validate exactly where it looked)."""
104
+ spec_path = "subdir/missing_model"
105
+ with runner.isolated_filesystem(temp_dir=tmp_path):
106
+ Path("subdir").mkdir()
107
+ result = runner.invoke(app, ["tlc", spec_path])
108
+
109
+ assert result.exit_code == 1
110
+ assert "Error: Could not find a TLA+ spec file. Looked in the following locations:" in result.output
111
+ assert f"- {spec_path}" in result.output
112
+ assert f"- {spec_path}.tla" in result.output
113
+ assert "- subdir/spec/missing_model.tla" in result.output
114
+
115
+
116
+ def test_tlc_classpath_includes_project_classes(mock_tlc_env, tmp_path, base_settings, mocker):
117
+ """When a classes/ directory exists next to the spec, it must appear in the JVM classpath."""
118
+ project_dir = tmp_path / "my_project"
119
+ spec_dir = project_dir
120
+ spec_dir.mkdir(parents=True)
121
+ (spec_dir / "queue.tla").write_text("MODULE queue\n===\n")
122
+ (project_dir / "classes").mkdir()
123
+
124
+ mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
125
+
126
+ with runner.isolated_filesystem(temp_dir=tmp_path):
127
+ result = runner.invoke(app, ["tlc", str(spec_dir / "queue.tla")])
128
+
129
+ assert result.exit_code == 0
130
+ args, _ = mock_tlc_env.call_args
131
+ cmd = args[0]
132
+ cp_index = cmd.index("-cp")
133
+ classpath = cmd[cp_index + 1]
134
+ assert str(project_dir / "classes") in classpath
135
+
136
+
137
+ def test_tlc_classpath_includes_lib_jars(mock_tlc_env, tmp_path, base_settings, mocker):
138
+ """When a lib/ directory with JARs exists, they must appear in the JVM classpath."""
139
+ project_dir = tmp_path / "my_project"
140
+ spec_dir = project_dir / "spec"
141
+ spec_dir.mkdir(parents=True)
142
+ (spec_dir / "queue.tla").write_text("MODULE queue\n===\n")
143
+ lib_dir = project_dir / "lib"
144
+ lib_dir.mkdir()
145
+ extra_jar = lib_dir / "extra.jar"
146
+ extra_jar.write_bytes(b"fake")
147
+
148
+ mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
149
+
150
+ with runner.isolated_filesystem(temp_dir=tmp_path):
151
+ result = runner.invoke(app, ["tlc", str(spec_dir / "queue.tla")])
152
+
153
+ assert result.exit_code == 0
154
+ args, _ = mock_tlc_env.call_args
155
+ cmd = args[0]
156
+ cp_index = cmd.index("-cp")
157
+ classpath = cmd[cp_index + 1]
158
+ assert str(extra_jar) in classpath
159
+
160
+
161
+ def test_tlc_tla_library_set_when_modules_dir_exists(mock_tlc_env, tmp_path, base_settings, mocker):
162
+ """-DTLA-Library is set when a modules/ directory is found."""
163
+ project_dir = tmp_path / "my_project"
164
+ spec_dir = project_dir / "tests"
165
+ spec_dir.mkdir(parents=True)
166
+ (spec_dir / "queue_test.tla").write_text("MODULE queue_test\n===\n")
167
+ modules_dir = project_dir / "modules"
168
+ modules_dir.mkdir()
169
+
170
+ mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
171
+
172
+ with runner.isolated_filesystem(temp_dir=tmp_path):
173
+ result = runner.invoke(app, ["tlc", str(spec_dir / "queue_test.tla")])
174
+
175
+ assert result.exit_code == 0
176
+ args, _ = mock_tlc_env.call_args
177
+ cmd = args[0]
178
+ assert f"-DTLA-Library={modules_dir}" in cmd
179
+
180
+
181
+ def test_tlc_no_project_dirs_no_extra_classpath(mock_tlc_env, tmp_path, base_settings, mocker):
182
+ """A spec without adjacent project dirs has no extra classpath entries."""
183
+ spec_dir = tmp_path / "standalone"
184
+ spec_dir.mkdir()
185
+ (spec_dir / "simple.tla").write_text("MODULE simple\n===\n")
186
+
187
+ mocker.patch("tlaplus_cli.run_tlc.load_config", return_value=base_settings)
188
+
189
+ with runner.isolated_filesystem(temp_dir=tmp_path):
190
+ result = runner.invoke(app, ["tlc", str(spec_dir / "simple.tla")])
191
+
192
+ assert result.exit_code == 0
193
+ args, _ = mock_tlc_env.call_args
194
+ cmd = args[0]
195
+ cp_index = cmd.index("-cp")
196
+ classpath = cmd[cp_index + 1]
197
+ # Should only contain the tla2tools.jar
198
+ assert "tla2tools.jar" in classpath
199
+ assert "classes" not in classpath
200
+ assert "lib" not in classpath
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import shutil
3
+ from pathlib import Path
3
4
  from unittest.mock import MagicMock
4
5
 
5
6
  import pytest
@@ -374,23 +375,24 @@ def test_tlc_upgrade_already_up_to_date(mock_github_api, mock_cache, mock_load_c
374
375
 
375
376
 
376
377
  def test_tlc_path_pinned_with_metadata(mock_load_config, mock_cache, installed_v180):
377
- """path shows TLC2 version string from metadata then the jar path."""
378
+ """path strictly outputs the absolute path to tla2tools.jar, even if metadata exists."""
378
379
  pin_file = mock_cache / "tools" / "tools-pinned-version.txt"
379
380
  pin_file.write_text("v1.8.0-aaaaaaa")
380
381
  # Write metadata
381
-
382
382
  meta = {"tlc2_version_string": "TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)"}
383
383
  (installed_v180 / "meta-tla2tools.json").write_text(json.dumps(meta))
384
384
 
385
385
  result = runner.invoke(app, ["tools", "path"])
386
386
  assert result.exit_code == 0
387
387
  lines = result.stdout.strip().splitlines()
388
- assert lines[0] == "TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)"
389
- assert "tla2tools.jar" in lines[1]
388
+ assert len(lines) == 1
389
+ assert "tla2tools.jar" in lines[0]
390
+ assert Path(lines[0]).is_absolute()
391
+ assert "TLC2 Version" not in result.stdout
390
392
 
391
393
 
392
394
  def test_tlc_path_pinned_without_metadata(mock_load_config, mock_cache, installed_v180):
393
- """path still works when no metadata exists — just shows the jar path."""
395
+ """path strictly outputs the absolute path to tla2tools.jar when no metadata exists."""
394
396
  pin_file = mock_cache / "tools" / "tools-pinned-version.txt"
395
397
  pin_file.write_text("v1.8.0-aaaaaaa")
396
398
 
@@ -399,28 +401,31 @@ def test_tlc_path_pinned_without_metadata(mock_load_config, mock_cache, installe
399
401
  lines = result.stdout.strip().splitlines()
400
402
  assert len(lines) == 1
401
403
  assert "tla2tools.jar" in lines[0]
404
+ assert Path(lines[0]).is_absolute()
402
405
 
403
406
 
404
407
  def test_tlc_path_version_with_metadata(mock_load_config, mock_cache, installed_v180):
405
- """path <version> shows metadata + jar path for a specific installed version."""
406
-
408
+ """path <version> strictly outputs the absolute path for a specific installed version."""
407
409
  meta = {"tlc2_version_string": "TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)"}
408
410
  (installed_v180 / "meta-tla2tools.json").write_text(json.dumps(meta))
409
411
 
410
412
  result = runner.invoke(app, ["tools", "path", "v1.8.0"])
411
413
  assert result.exit_code == 0
412
414
  lines = result.stdout.strip().splitlines()
413
- assert lines[0] == "TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802)"
414
- assert "tla2tools.jar" in lines[1]
415
+ assert len(lines) == 1
416
+ assert "tla2tools.jar" in lines[0]
417
+ assert Path(lines[0]).is_absolute()
418
+ assert "TLC2 Version" not in result.stdout
415
419
 
416
420
 
417
421
  def test_tlc_path_version_without_metadata(mock_load_config, mock_cache, installed_v180):
418
- """path <version> works without metadata — just shows jar path."""
422
+ """path <version> strictly outputs the absolute path when no metadata exists."""
419
423
  result = runner.invoke(app, ["tools", "path", "v1.8.0"])
420
424
  assert result.exit_code == 0
421
425
  lines = result.stdout.strip().splitlines()
422
426
  assert len(lines) == 1
423
427
  assert "tla2tools.jar" in lines[0]
428
+ assert Path(lines[0]).is_absolute()
424
429
 
425
430
 
426
431
  def test_tlc_path_not_found(mock_load_config, mock_cache):
@@ -1,77 +0,0 @@
1
- import shutil
2
-
3
- import pytest
4
- from typer.testing import CliRunner
5
-
6
- from tlaplus_cli.cli import app
7
-
8
- runner = CliRunner()
9
-
10
- # Define paths relative to the test file
11
- # Check if javac is available
12
- JAVAC_AVAILABLE = shutil.which("javac") is not None
13
-
14
-
15
- @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
16
- def test_build_integration(mocker, tmp_path, queue_dir, base_settings):
17
- """
18
- Integration test for build_tlc_module.build().
19
- Uses the actual files in tla-example and the real tla2tools.jar (if available).
20
- Verified output classes are generated in a temporary directory.
21
- """
22
- # Create a temporary classes directory
23
- classes_dir = tmp_path / "classes"
24
-
25
- # Configure base_settings for this test
26
- base_settings.workspace.root = queue_dir
27
- base_settings.workspace.classes_dir = classes_dir
28
-
29
- # Patch load_config to return our mock config
30
- mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
31
-
32
- # Patch workspace_root to return the example directory
33
- mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=queue_dir)
34
-
35
- # Run build
36
- result = runner.invoke(app, ["build", "--verbose"])
37
- assert result.exit_code == 0, f"Build failed with {result.exit_code}: {result.stdout}"
38
-
39
- # Verify outputs
40
- assert classes_dir.exists(), "Classes directory not created"
41
-
42
- # Verify TLCOverrides.class exists
43
- # The source is modules/tlc2/overrides/TLCOverrides.java
44
- # Package is tlc2.overrides, so output should be in tlc2/overrides/
45
- output_class = classes_dir / "tlc2/overrides/TLCOverrides.class"
46
- assert output_class.exists(), f"Class file not found at {output_class}"
47
-
48
- # Verify META-INF service file
49
- service_file = classes_dir / "META-INF/services/tlc2.overrides.ITLCOverrides"
50
- assert service_file.exists(), "Service file not found"
51
-
52
- content = service_file.read_text().strip()
53
- assert content == "tlc2.overrides.TLCOverrides", f"Unexpected service file content: {content}"
54
-
55
-
56
- @pytest.mark.skipif(not JAVAC_AVAILABLE, reason="javac not found")
57
- def test_build_custom_overrides(mocker, tmp_path, queue_dir, base_settings):
58
- """
59
- Test build with custom overrides class configuration.
60
- """
61
- classes_dir = tmp_path / "classes"
62
-
63
- # Configure custom override
64
- custom_override = "com.example.MyOverrides"
65
- base_settings.tlc.overrides_class = custom_override
66
- base_settings.workspace.root = queue_dir
67
- base_settings.workspace.classes_dir = classes_dir
68
-
69
- mocker.patch("tlaplus_cli.build_tlc_module.load_config", return_value=base_settings)
70
- mocker.patch("tlaplus_cli.build_tlc_module.workspace_root", return_value=queue_dir)
71
-
72
- result = runner.invoke(app, ["build"])
73
- assert result.exit_code == 0, f"Build failed: {result.stdout}"
74
-
75
- service_file = classes_dir / "META-INF/services/tlc2.overrides.ITLCOverrides"
76
- assert service_file.exists()
77
- assert service_file.read_text().strip() == custom_override
File without changes
File without changes
File without changes