fogies 0.0.0.dev1__tar.gz → 0.0.0.dev3__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.
@@ -1,12 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev1
3
+ Version: 0.0.0.dev3
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
7
7
  Requires-Python: >=3.14,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.14
10
+ Requires-Dist: filelock (>=3.25.2,<4.0.0)
11
+ Requires-Dist: ollama (>=0.6.1,<0.7.0)
12
+ Requires-Dist: psutil (>=7.2.2,<8.0.0)
13
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
10
14
  Description-Content-Type: text/markdown
11
15
 
12
16
  # pyfogies
@@ -0,0 +1,21 @@
1
+ """Paths used by tasks and tests in this development environment."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Secrets directory.
6
+ PATH_SECRETS = Path("secrets")
7
+
8
+ # Path to the AWS profile secrets configuration file.
9
+ PATH_SECRETS_AWS = PATH_SECRETS / "aws.toml"
10
+
11
+ # AWS profile name for pyfogies test environment.
12
+ AWS_PROFILE_PYFOGIES_TEST = "pyfogies-test"
13
+
14
+ # Path to the Poetry secrets configuration file.
15
+ PATH_SECRETS_POETRY = PATH_SECRETS / "poetry.toml"
16
+
17
+ # Staging directory.
18
+ PATH_STAGING = Path(".staging")
19
+
20
+ # Binary cache directory (inside staging).
21
+ PATH_STAGING_BINARY_CACHE = PATH_STAGING / "bin"
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fogies"
3
3
  # Version follows semantic versioning <https://semver.org/>.
4
- version = "0.0.0-dev.1"
4
+ version = "0.0.0-dev.3"
5
5
  description = ""
6
6
  authors = [
7
7
  {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
@@ -10,9 +10,16 @@ readme = "README.md"
10
10
  requires-python = ">=3.14,<4.0"
11
11
 
12
12
  [tool.poetry]
13
- packages = [{include = "fogies", from = "src"}]
13
+ packages = [
14
+ {include = "fogies", from = "src"},
15
+ {include = "fogies_paths.py", from = "."},
16
+ ]
14
17
 
15
18
  [tool.poetry.dependencies]
19
+ pydantic = "^2.12.5"
20
+ ollama = "^0.6.1"
21
+ filelock = "^3.25.2"
22
+ psutil = "^7.2.2"
16
23
 
17
24
  [tool.poetry.group.dev.dependencies]
18
25
  # ^ allows all minor and patch releases.
@@ -21,6 +28,8 @@ packages = [{include = "fogies", from = "src"}]
21
28
  # Example: ~1.2.3 will match versions >=1.2.3 but <1.3.0.
22
29
  basedpyright = "^1.36.1"
23
30
  black = "~25.12.0" # Black does not semver.
31
+ boto3 = "^1.42.68"
32
+ boto3-stubs-full = "^1.42.68"
24
33
  invoke = "^2.2.1"
25
34
  isort = "^7.0.0"
26
35
  poetry = "^2.2.1"
@@ -30,10 +39,9 @@ semver = "^3.0.4"
30
39
  [tool.basedpyright]
31
40
  typeCheckingMode = "recommended"
32
41
  pythonVersion = "3.14"
33
- include = ["src", "tasks", "tests"]
42
+ include = ["fogies_paths.py", "src", "tasks", "tests"]
34
43
  allowedUntypedLibraries = ["invoke"]
35
44
 
36
45
  [build-system]
37
46
  requires = ["poetry-core>=2.0.0,<3.0.0"]
38
47
  build-backend = "poetry.core.masonry.api"
39
-
@@ -10,7 +10,7 @@ from invoke.context import Context
10
10
  from invoke.tasks import Task, task
11
11
 
12
12
 
13
- def get_task_test() -> Task[Callable[[Context], None]]:
13
+ def get_task_test(path_tests: str | None = None) -> Task[Callable[[Context], None]]:
14
14
  @task(name="test") # pyright: ignore[reportUntypedFunctionDecorator]
15
15
  def task_test(context: Context) -> None:
16
16
  """
@@ -21,6 +21,9 @@ def get_task_test() -> Task[Callable[[Context], None]]:
21
21
  env = os.environ.copy()
22
22
  env["COLUMNS"] = str(shutil.get_terminal_size().columns)
23
23
 
24
+ # Default to current directory.
25
+ param_path_tests = path_tests if path_tests is not None else "."
26
+
24
27
  _ = context.run(
25
28
  command=" ".join(
26
29
  [
@@ -28,7 +31,7 @@ def get_task_test() -> Task[Callable[[Context], None]]:
28
31
  # Explicitly enable color output.
29
32
  # Without this, output through invoke will not be in color.
30
33
  "--color=yes",
31
- ".",
34
+ param_path_tests,
32
35
  ]
33
36
  ),
34
37
  echo=True,
File without changes
@@ -0,0 +1,17 @@
1
+ """Pydantic models for Terraform backend module variables and output."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class BackendVars(BaseModel):
7
+ name: str
8
+ region: str
9
+ states: list[str] = []
10
+ tags: dict[str, str] = {}
11
+ force_destroy: bool = False
12
+
13
+
14
+ class BackendOutput(BaseModel):
15
+ bucket_name: str
16
+ region: str
17
+ state_keys: dict[str, str]
File without changes
@@ -0,0 +1,80 @@
1
+ """Helpers for configuring AWS-related environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from fogies.tools.environ import environ
12
+
13
+
14
+ class _AwsProfile(BaseModel):
15
+ aws_access_key_id: str
16
+ aws_secret_access_key: str
17
+
18
+
19
+ def _load_aws_profile_from_toml(profiles_path: Path, profile: str) -> _AwsProfile:
20
+ """Return AWS profile loaded from a TOML profiles file.
21
+
22
+ The file is expected to contain a table for each profile, for example:
23
+
24
+ [test]
25
+ aws_access_key_id = "value-id"
26
+ aws_secret_access_key = "value-secret"
27
+ """
28
+ if profiles_path.suffix != ".toml":
29
+ raise ValueError(
30
+ "AWS profiles file must have .toml extension, got '{}'".format(
31
+ profiles_path
32
+ )
33
+ )
34
+ if not profiles_path.exists():
35
+ raise FileNotFoundError(
36
+ "AWS profiles file '{}' does not exist".format(profiles_path)
37
+ )
38
+
39
+ with profiles_path.open("rb") as profiles_file:
40
+ data: dict[str, object] = tomllib.load(profiles_file)
41
+
42
+ try:
43
+ profile_raw = data[profile]
44
+ except KeyError as exc:
45
+ raise KeyError(
46
+ "AWS profile '{}' not found in '{}'".format(
47
+ profile,
48
+ profiles_path,
49
+ )
50
+ ) from exc
51
+
52
+ return _AwsProfile.model_validate(profile_raw)
53
+
54
+
55
+ def aws_environ(
56
+ profiles_path: Path,
57
+ profile: str,
58
+ *,
59
+ raise_if_exists: bool = True,
60
+ raise_if_changed: bool = True,
61
+ ) -> contextlib.AbstractContextManager[None]:
62
+ """Return a context manager that applies AWS variables from a TOML file.
63
+
64
+ The *profiles_path* parameter specifies the AWS TOML profiles file to read;
65
+ it must have a ``.toml`` extension. The *profile* parameter specifies the
66
+ AWS profile name, which is mapped to a ``[<name>]`` table in the profiles
67
+ file.
68
+ """
69
+ aws_profile = _load_aws_profile_from_toml(
70
+ profiles_path=profiles_path, profile=profile
71
+ )
72
+ variables: dict[str, str] = {
73
+ "AWS_ACCESS_KEY_ID": aws_profile.aws_access_key_id,
74
+ "AWS_SECRET_ACCESS_KEY": aws_profile.aws_secret_access_key,
75
+ }
76
+ return environ(
77
+ variables=variables,
78
+ raise_if_exists=raise_if_exists,
79
+ raise_if_changed=raise_if_changed,
80
+ )
@@ -0,0 +1,78 @@
1
+ import contextlib
2
+ import dataclasses
3
+ import os
4
+ import pathlib
5
+ from typing import cast
6
+
7
+ from invoke.context import Context
8
+ from invoke.runners import Result
9
+
10
+
11
+ @dataclasses.dataclass(frozen=True, slots=True)
12
+ class CommandParams:
13
+ """Params for execution via invoke."""
14
+
15
+ context: Context | None = None
16
+ cwd: pathlib.Path | None = None
17
+ in_stream: bool = True
18
+
19
+ def require_cwd(self, path: pathlib.Path) -> "CommandParams":
20
+ """Ensure cwd is set to *path*; return updated params or raise if conflicting."""
21
+ if self.cwd is None:
22
+ return dataclasses.replace(self, cwd=path)
23
+ if self.cwd == path:
24
+ return self
25
+ raise ValueError(
26
+ "CommandParams requires cwd '{}' but already has '{}'".format(
27
+ path,
28
+ self.cwd,
29
+ )
30
+ )
31
+
32
+
33
+ def _resolve_command(
34
+ command: pathlib.Path,
35
+ cwd: pathlib.Path | None,
36
+ ) -> str:
37
+ """Return the command string to pass to the shell.
38
+
39
+ When *cwd* is set and *command* resolves to an existing file (a specific
40
+ binary), returns a path relative to *cwd*. Otherwise returns the command as
41
+ given so a name like \"bash\" is left for PATH (e.g. shutil.which).
42
+ """
43
+ if cwd is not None:
44
+ resolved = command.resolve()
45
+ if resolved.exists():
46
+ return os.path.relpath(resolved, cwd.resolve())
47
+ return str(command)
48
+
49
+
50
+ def command_run(
51
+ *,
52
+ command: pathlib.Path,
53
+ command_params: CommandParams | None = None,
54
+ args: list[str] | None = None,
55
+ ) -> Result:
56
+ """Run a command via invoke run().
57
+
58
+ If *context* is provided, use context.run(). Otherwise create a new
59
+ Context and run the command there.
60
+ """
61
+ command_params = command_params or CommandParams()
62
+ context = command_params.context or Context()
63
+
64
+ resolved_command = _resolve_command(command, command_params.cwd)
65
+ args_combined = [resolved_command] + (args or [])
66
+ command_str = " ".join(args_combined)
67
+
68
+ cd_context = (
69
+ context.cd(str(command_params.cwd)) # pyright: ignore[reportUnknownMemberType]
70
+ if command_params.cwd is not None
71
+ else contextlib.nullcontext()
72
+ )
73
+ with cd_context:
74
+ result = context.run(command_str, in_stream=command_params.in_stream)
75
+
76
+ # invoke's Context.run() returns None when run with disown=True.
77
+ # Ensure future revisions to this code never introduce the parameter.
78
+ return cast(Result, result)
@@ -0,0 +1,108 @@
1
+ """Context managers for temporary environment variable overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ from collections.abc import Mapping
8
+
9
+
10
+ class _EnvironContext:
11
+ """Context manager for environment variable overrides."""
12
+
13
+ def __init__(
14
+ self,
15
+ variables: Mapping[str, str],
16
+ *,
17
+ raise_if_exists: bool,
18
+ raise_if_changed: bool,
19
+ ) -> None:
20
+ self._variables: dict[str, str] = {
21
+ name: str(value) for name, value in variables.items()
22
+ }
23
+ self._raise_if_exists: bool = raise_if_exists
24
+ self._raise_if_changed: bool = raise_if_changed
25
+ self._original_variables: dict[str, str | None] = {}
26
+ self._applied_variables: list[str] = []
27
+
28
+ def _restore_originals(self) -> None:
29
+ """Restore each applied variable to its original value or remove it."""
30
+ for name in reversed(self._applied_variables):
31
+ original = self._original_variables.get(name)
32
+ if original is None:
33
+ if name in os.environ:
34
+ del os.environ[name]
35
+ else:
36
+ os.environ[name] = original
37
+
38
+ def __enter__(self) -> None:
39
+ try:
40
+ for name, value in self._variables.items():
41
+ existing = os.environ.get(name)
42
+ if existing is not None and self._raise_if_exists:
43
+ raise ValueError(
44
+ "Environment variable '{}' already exists".format(name)
45
+ )
46
+
47
+ self._original_variables[name] = existing
48
+ os.environ[name] = value
49
+ self._applied_variables.append(name)
50
+ except Exception:
51
+ # Roll back any changes made before the failure.
52
+ self._restore_originals()
53
+ raise
54
+ return None
55
+
56
+ def __exit__(
57
+ self,
58
+ exc_type: type[BaseException] | None,
59
+ exc: BaseException | None,
60
+ exc_tb: object | None,
61
+ ) -> bool:
62
+ error: RuntimeError | None = None
63
+
64
+ # Enforce raise_if_changed only when the body exited normally.
65
+ if exc_type is None and self._raise_if_changed:
66
+ for name in self._applied_variables:
67
+ expected = self._variables[name]
68
+ current = os.environ.get(name)
69
+ if current != expected:
70
+ error = RuntimeError(
71
+ "Environment variable '{}' was modified while context manager was active".format(
72
+ name
73
+ )
74
+ )
75
+ break
76
+
77
+ # Always restore original values.
78
+ self._restore_originals()
79
+
80
+ if error is not None:
81
+ raise error
82
+
83
+ return False
84
+
85
+
86
+ def environ(
87
+ variables: Mapping[str, str],
88
+ *,
89
+ raise_if_exists: bool = True,
90
+ raise_if_changed: bool = True,
91
+ ) -> contextlib.AbstractContextManager[None]:
92
+ """Return a context manager that applies the given environment overrides.
93
+
94
+ The *variables* mapping provides environment variable names and string values
95
+ to assign for the duration of the context.
96
+
97
+ For each variable:
98
+ - If raise_if_exists is True (default) and the variable already exists,
99
+ raises ValueError and leaves the environment unchanged.
100
+ - On normal exit, if raise_if_changed is True (default) and the value in
101
+ the environment differs from the value set by this context manager,
102
+ raises RuntimeError.
103
+ """
104
+ return _EnvironContext(
105
+ variables=variables,
106
+ raise_if_exists=raise_if_exists,
107
+ raise_if_changed=raise_if_changed,
108
+ )
@@ -0,0 +1,351 @@
1
+ import io
2
+ import pathlib
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import urllib.request
9
+ import zipfile
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from http.client import HTTPResponse
13
+ from typing import cast
14
+
15
+ import ollama as _ollama_client
16
+ import psutil
17
+ from filelock import BaseFileLock, FileLock
18
+ from pydantic import BaseModel
19
+
20
+ _KNOWN_VERSIONS = [
21
+ "0.17.7",
22
+ ]
23
+
24
+ _DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
25
+
26
+ _OLLAMA_URL_TEMPLATE = (
27
+ "https://github.com/ollama/ollama/releases/download"
28
+ "/v{version}/ollama-windows-amd64.zip"
29
+ )
30
+
31
+ _OLLAMA_LISTEN_ADDRESS: tuple[str, int] = ("127.0.0.1", 11434)
32
+ _OLLAMA_LISTEN_PROBE_TIMEOUT = 0.25
33
+ _OLLAMA_LISTEN_POLL_INTERVAL = 0.1
34
+ _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT = 10.0
35
+ _OLLAMA_TERMINATE_WAIT_TIMEOUT = 10.0
36
+
37
+
38
+ class _PidWithCreateTime(BaseModel):
39
+ """Persisted process identity state."""
40
+
41
+ pid: int
42
+ create_time_seconds: int
43
+
44
+
45
+ class _Ollama:
46
+ """Represents an Ollama CLI binary."""
47
+
48
+ _version: str
49
+ _path: pathlib.Path
50
+ _file_lock: BaseFileLock
51
+
52
+ def __init__(self, *, version: str, path: pathlib.Path) -> None:
53
+ self._version = version
54
+ self._path = path
55
+ self._file_lock = FileLock(str(self._lock_path), is_singleton=True)
56
+
57
+ @property
58
+ def binary_version(self) -> str:
59
+ """The Ollama binary version string."""
60
+ return self._version
61
+
62
+ @property
63
+ def binary_path(self) -> pathlib.Path:
64
+ """The path to the Ollama executable."""
65
+ return self._path
66
+
67
+ @property
68
+ def _pid_path(self) -> pathlib.Path:
69
+ """Return the PID file path for this binary."""
70
+ return self._path.with_suffix(".pid")
71
+
72
+ @property
73
+ def _refcount_path(self) -> pathlib.Path:
74
+ """Return the refcount file path for this binary."""
75
+ return self._path.with_suffix(".refcount")
76
+
77
+ @property
78
+ def _lock_path(self) -> pathlib.Path:
79
+ """Return the lock file path for this binary."""
80
+ return self._path.with_suffix(".lock")
81
+
82
+ @property
83
+ def pid(self) -> _PidWithCreateTime | None:
84
+ """Return process identity state, or None if not managed."""
85
+ self._assert_lock()
86
+
87
+ pid_path = self._pid_path
88
+ if not pid_path.exists():
89
+ return None
90
+
91
+ text = pid_path.read_text(encoding="utf-8")
92
+ state = _PidWithCreateTime.model_validate_json(text)
93
+ assert state.pid > 0
94
+
95
+ return state
96
+
97
+ @property
98
+ def refcount(self) -> int:
99
+ """Return the server refcount, or 0 if not managed."""
100
+ self._assert_lock()
101
+
102
+ refcount_path = self._refcount_path
103
+ if not refcount_path.exists():
104
+ return 0
105
+
106
+ text = refcount_path.read_text(encoding="utf-8").strip()
107
+ if not text:
108
+ return 0
109
+
110
+ return int(text)
111
+
112
+ @contextmanager
113
+ def lock(self) -> Iterator[None]:
114
+ """Acquire and hold the file lock."""
115
+ with self._file_lock:
116
+ yield
117
+
118
+ def _assert_lock(self) -> None:
119
+ assert (
120
+ self._file_lock.is_locked
121
+ ), "Must be accessed within an Ollama lock context."
122
+
123
+ def reset_state(self) -> None:
124
+ """Clear the server state files."""
125
+ self._assert_lock()
126
+
127
+ self._pid_path.unlink(missing_ok=True)
128
+ self._refcount_path.unlink(missing_ok=True)
129
+
130
+ def set_pid(self, pid: _PidWithCreateTime) -> None:
131
+ """Set the managed server PID state."""
132
+ self._assert_lock()
133
+
134
+ pid_path = self._pid_path
135
+
136
+ text = "{}\n".format(pid.model_dump_json())
137
+ _ = pid_path.write_text(text, encoding="utf-8")
138
+
139
+ def set_refcount(self, count: int) -> None:
140
+ """Set the managed server refcount state."""
141
+ self._assert_lock()
142
+
143
+ refcount_path = self._refcount_path
144
+
145
+ _ = refcount_path.write_text(
146
+ "{}\n".format(count),
147
+ encoding="utf-8",
148
+ )
149
+
150
+
151
+ def _create_time_seconds(create_time: float) -> int:
152
+ """Convert process create_time to truncated integer seconds."""
153
+ return int(create_time)
154
+
155
+
156
+ def _terminate_pid(*, pid: _PidWithCreateTime) -> None:
157
+ try:
158
+ process = psutil.Process(pid.pid)
159
+
160
+ # If the process has been restarted, the pid was reused, do not terminate it.
161
+ process_create_time_seconds = _create_time_seconds(process.create_time())
162
+ if process_create_time_seconds != pid.create_time_seconds:
163
+ return
164
+
165
+ children = process.children(recursive=True)
166
+ processes = [*children, process]
167
+
168
+ for process_to_end in processes:
169
+ try:
170
+ process_to_end.terminate()
171
+ except psutil.Error:
172
+ pass
173
+
174
+ _, alive = psutil.wait_procs(
175
+ processes,
176
+ timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
177
+ )
178
+ if not alive:
179
+ return
180
+
181
+ for process_to_kill in alive:
182
+ try:
183
+ process_to_kill.kill()
184
+ except psutil.Error:
185
+ pass
186
+
187
+ _ = psutil.wait_procs(
188
+ alive,
189
+ timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
190
+ )
191
+ except psutil.Error:
192
+ pass
193
+
194
+
195
+ def _wait_until_listening(
196
+ *,
197
+ pid: _PidWithCreateTime,
198
+ timeout: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
199
+ ) -> None:
200
+ end = time.time() + timeout
201
+ while time.time() < end:
202
+ try:
203
+ proc = psutil.Process(pid.pid)
204
+ alive = (
205
+ proc.is_running()
206
+ and proc.status() != psutil.STATUS_ZOMBIE
207
+ and _create_time_seconds(proc.create_time()) == pid.create_time_seconds
208
+ )
209
+ except psutil.Error:
210
+ alive = False
211
+ if not alive:
212
+ raise RuntimeError("Ollama server process exited before becoming ready")
213
+ try:
214
+ with socket.create_connection(
215
+ _OLLAMA_LISTEN_ADDRESS,
216
+ timeout=_OLLAMA_LISTEN_PROBE_TIMEOUT,
217
+ ):
218
+ return
219
+ except OSError:
220
+ pass
221
+ time.sleep(_OLLAMA_LISTEN_POLL_INTERVAL)
222
+ raise TimeoutError("Ollama server did not become ready")
223
+
224
+
225
+ @contextmanager
226
+ def ollama(
227
+ *,
228
+ version: str | None = None,
229
+ binary_cache_path: pathlib.Path,
230
+ ) -> Iterator[_Ollama]:
231
+ """Download an Ollama Windows CLI release and yield an Ollama handle.
232
+
233
+ *version* is the Ollama release tag version (e.g., "0.17.7"). The archive is
234
+ downloaded from the GitHub releases page if it does not already exist in
235
+ *binary_cache_path*. The CLI zip archive `ollama-windows-amd64.zip` is
236
+ fetched and the full folder structure is extracted into a versioned
237
+ directory inside *binary_cache_path* and used from there.
238
+ """
239
+ if sys.platform != "win32":
240
+ raise RuntimeError("Only implemented on Windows")
241
+
242
+ if version is None:
243
+ version = _DEFAULT_VERSION
244
+
245
+ if version not in _KNOWN_VERSIONS:
246
+ known = ", ".join(_KNOWN_VERSIONS)
247
+ raise ValueError(
248
+ "Unknown Ollama version '{}'; known versions: {}".format(
249
+ version,
250
+ known,
251
+ )
252
+ )
253
+
254
+ dir_name = "ollama_{}".format(version.replace(".", "_"))
255
+ version_dir = binary_cache_path / dir_name
256
+ exe_path = version_dir / "ollama.exe"
257
+
258
+ if not exe_path.exists():
259
+ version_dir.mkdir(parents=True, exist_ok=True)
260
+ try:
261
+ url = _OLLAMA_URL_TEMPLATE.format(version=version)
262
+ response = cast(HTTPResponse, urllib.request.urlopen(url))
263
+ with response:
264
+ zip_bytes: bytes = response.read()
265
+
266
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
267
+ zf.extractall(version_dir)
268
+ except Exception:
269
+ shutil.rmtree(version_dir, ignore_errors=True)
270
+ raise
271
+
272
+ if not exe_path.exists():
273
+ raise RuntimeError(
274
+ "Ollama executable 'ollama.exe' not found in '{}'".format(version_dir)
275
+ )
276
+
277
+ yield _Ollama(version=version, path=exe_path)
278
+
279
+
280
+ @contextmanager
281
+ def ollama_client(
282
+ *,
283
+ version: str | None = None,
284
+ binary_cache_path: pathlib.Path,
285
+ show_window: bool = False,
286
+ ) -> Iterator[_ollama_client.Client]:
287
+ """Provide an Ollama Python client with a running local server.
288
+
289
+ Starts Ollama server in background and yields a configured
290
+ :class:`ollama.Client` instance connected to default local host.
291
+ Set *show_window* to True to allow a visible console window.
292
+ """
293
+ with ollama(version=version, binary_cache_path=binary_cache_path) as ollama_binary:
294
+ with ollama_binary.lock():
295
+ # First check whether a process is already running.
296
+ running = False
297
+ if ollama_binary.pid is not None:
298
+ try:
299
+ proc = psutil.Process(ollama_binary.pid.pid)
300
+ running = (
301
+ proc.is_running()
302
+ and proc.status() != psutil.STATUS_ZOMBIE
303
+ and _create_time_seconds(proc.create_time())
304
+ == ollama_binary.pid.create_time_seconds
305
+ )
306
+ except psutil.Error:
307
+ running = False
308
+
309
+ # If needed, start the server.
310
+ if not running:
311
+ ollama_binary.reset_state()
312
+
313
+ stdout = None if show_window else subprocess.DEVNULL
314
+ stderr = None if show_window else subprocess.DEVNULL
315
+ creation_flags = subprocess.CREATE_NEW_CONSOLE if show_window else 0
316
+
317
+ server_process = subprocess.Popen(
318
+ [str(ollama_binary.binary_path), "serve"],
319
+ stdout=stdout,
320
+ stderr=stderr,
321
+ creationflags=creation_flags,
322
+ )
323
+ ollama_binary.set_pid(
324
+ _PidWithCreateTime(
325
+ pid=server_process.pid,
326
+ create_time_seconds=_create_time_seconds(
327
+ psutil.Process(server_process.pid).create_time()
328
+ ),
329
+ )
330
+ )
331
+
332
+ # We now believe it is running.
333
+ assert ollama_binary.pid is not None
334
+
335
+ # Increment the refcount.
336
+ ollama_binary.set_refcount(ollama_binary.refcount + 1)
337
+
338
+ # Wait until the server is listening.
339
+ _wait_until_listening(pid=ollama_binary.pid)
340
+
341
+ try:
342
+ # Create the client.
343
+ client = _ollama_client.Client()
344
+ yield client
345
+ finally:
346
+ with ollama_binary.lock():
347
+ ollama_binary.set_refcount(ollama_binary.refcount - 1)
348
+
349
+ if ollama_binary.refcount == 0:
350
+ _terminate_pid(pid=ollama_binary.pid)
351
+ ollama_binary.reset_state()
@@ -0,0 +1,445 @@
1
+ import dataclasses
2
+ import io
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ import urllib.request
7
+ import zipfile
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from http.client import HTTPResponse
11
+ from typing import TypeVar, cast
12
+
13
+ from invoke.runners import Result
14
+ from pydantic import BaseModel, RootModel
15
+
16
+ from fogies.terraform.backend import BackendOutput
17
+ from fogies.tools.command import CommandParams, command_run
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True, slots=True)
21
+ class InitParams:
22
+ """Params for terraform init."""
23
+
24
+ migrate_state: bool = False
25
+ reconfigure: bool = False
26
+ upgrade: bool = False
27
+
28
+
29
+ @dataclasses.dataclass(frozen=True, slots=True)
30
+ class ApplyParams:
31
+ """Params for terraform apply."""
32
+
33
+ auto_approve: bool = False
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True, slots=True)
37
+ class DestroyParams:
38
+ """Params for terraform destroy."""
39
+
40
+ auto_approve: bool = False
41
+
42
+
43
+ _KNOWN_VERSIONS = [
44
+ "1.14.4",
45
+ ]
46
+
47
+ _DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
48
+
49
+ _TERRAFORM_URL_TEMPLATE = (
50
+ "https://releases.hashicorp.com/terraform"
51
+ "/{version}/terraform_{version}_windows_amd64.zip"
52
+ )
53
+
54
+ TerraformOutputModel = TypeVar("TerraformOutputModel", bound=BaseModel)
55
+
56
+
57
+ class _TerraformCommandOutputEntryModel(BaseModel):
58
+ """One output entry from `terraform output -json`, wrapping a value."""
59
+
60
+ value: object
61
+ type: object
62
+ sensitive: bool
63
+
64
+
65
+ class _TerraformCommandOutputModel(
66
+ RootModel[dict[str, _TerraformCommandOutputEntryModel]],
67
+ ):
68
+ """Root model for the full `terraform output -json` payload."""
69
+
70
+
71
+ @contextmanager
72
+ def terraform_tfbackend_s3(
73
+ *,
74
+ path: pathlib.Path,
75
+ backend: BackendOutput,
76
+ state: str,
77
+ delete_on_exit: bool = True,
78
+ ) -> Iterator[pathlib.Path]:
79
+ """Write S3 backend configuration to a file and yield the path.
80
+
81
+ The file is written as flat key/value entries, one per line, e.g.:
82
+
83
+ region = "us-west-2"
84
+ bucket = "pyfogies-test-backend-bucket"
85
+ key = "test-state-a/terraform.tfstate"
86
+ use_lockfile = true
87
+ """
88
+ if path.suffixes[-2:] != [".s3", ".tfbackend"]:
89
+ raise ValueError("Path '{}' must end with '.s3.tfbackend'".format(path))
90
+ if state not in backend.state_keys:
91
+ raise ValueError(
92
+ "State '{}' is not declared as part of backend. Declared states: {}.".format(
93
+ state,
94
+ ", ".join(sorted(backend.state_keys)),
95
+ )
96
+ )
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ with path.open("w", encoding="utf-8") as f:
99
+ _ = f.write('region = "{}"\n'.format(backend.region))
100
+ _ = f.write('bucket = "{}"\n'.format(backend.bucket_name))
101
+ _ = f.write('key = "{}"\n'.format(backend.state_keys[state]))
102
+ _ = f.write("use_lockfile = true\n")
103
+ try:
104
+ yield path
105
+ finally:
106
+ if delete_on_exit and path.exists():
107
+ path.unlink()
108
+
109
+
110
+ @contextmanager
111
+ def terraform_tfvars(
112
+ *,
113
+ path: pathlib.Path,
114
+ variables: BaseModel,
115
+ delete_on_exit: bool = True,
116
+ ) -> Iterator[pathlib.Path]:
117
+ """Write in-memory variables to a file and yield the path for use with apply/destroy.
118
+
119
+ *path* is where the .tfvars.json file is written. *variables* must be a
120
+ Pydantic model; its fields are written as the Terraform variable set.
121
+ Yields *path* so the caller can pass it as the tfvars argument to apply()
122
+ or destroy(). If *delete_on_exit* is true, remove the file when exiting the
123
+ context.
124
+ """
125
+ suffixes = path.suffixes
126
+ if suffixes[-2:] != [".tfvars", ".json"]:
127
+ raise ValueError("Path '{}' must end with '.tfvars.json'".format(path))
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ with path.open("w", encoding="utf-8") as f:
130
+ json.dump(variables.model_dump(mode="json"), f, indent=2)
131
+ try:
132
+ yield path
133
+ finally:
134
+ if delete_on_exit and path.exists():
135
+ path.unlink()
136
+
137
+
138
+ class _Terraform:
139
+ """Represents a Terraform binary."""
140
+
141
+ _version: str
142
+ _path: pathlib.Path
143
+
144
+ def __init__(self, *, version: str, path: pathlib.Path) -> None:
145
+ self._version = version
146
+ self._path = path
147
+
148
+ @property
149
+ def binary_version(self) -> str:
150
+ """The Terraform binary version string."""
151
+ return self._version
152
+
153
+ @property
154
+ def binary_path(self) -> pathlib.Path:
155
+ """The path to the Terraform executable."""
156
+ return self._path
157
+
158
+ def init(
159
+ self,
160
+ *,
161
+ command_params: CommandParams,
162
+ module_path: pathlib.Path,
163
+ tfbackend_path: pathlib.Path | None = None,
164
+ init_params: InitParams | None = None,
165
+ ) -> Result:
166
+ """Run terraform init.
167
+
168
+ *module_path* is the folder containing the Terraform files.
169
+ *init_params.migrate_state* when true passes -migrate-state to terraform init.
170
+ *init_params.reconfigure* when true passes -reconfigure to terraform init.
171
+ *init_params.upgrade* when true passes -upgrade to terraform init.
172
+ *tfbackend_path* when set passes -backend-config=<path> to terraform
173
+ init, where <path> is a backend configuration file.
174
+ """
175
+ command_params = command_params.require_cwd(module_path)
176
+ if init_params is None:
177
+ init_params = InitParams()
178
+ init_args = ["init"]
179
+ if tfbackend_path is not None:
180
+ init_args.extend(["-backend-config", str(tfbackend_path)])
181
+ if init_params.migrate_state:
182
+ init_args.append("-migrate-state")
183
+ if init_params.reconfigure:
184
+ init_args.append("-reconfigure")
185
+ if init_params.upgrade:
186
+ init_args.append("-upgrade")
187
+ return command_run(
188
+ command=self.binary_path,
189
+ command_params=command_params,
190
+ args=init_args,
191
+ )
192
+
193
+ def apply(
194
+ self,
195
+ *,
196
+ command_params: CommandParams,
197
+ module_path: pathlib.Path,
198
+ tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
199
+ apply_params: ApplyParams | None = None,
200
+ ) -> Result:
201
+ """Run terraform apply.
202
+
203
+ *module_path* is the folder containing the Terraform files (used as
204
+ working directory). If *apply_params.auto_approve* is true, pass
205
+ -auto-approve. *tfvars_path* is optional; when set, pass -var-file for
206
+ each path.
207
+ """
208
+ command_params = command_params.require_cwd(module_path)
209
+ if apply_params is None:
210
+ apply_params = ApplyParams()
211
+
212
+ apply_args = ["apply"]
213
+ if apply_params.auto_approve:
214
+ apply_args.append("-auto-approve")
215
+ if tfvars_path is not None:
216
+ paths = (
217
+ [tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
218
+ )
219
+ for p in paths:
220
+ apply_args.extend(["-var-file", str(p)])
221
+
222
+ return command_run(
223
+ command=self.binary_path,
224
+ command_params=command_params,
225
+ args=apply_args,
226
+ )
227
+
228
+ def destroy(
229
+ self,
230
+ *,
231
+ command_params: CommandParams,
232
+ module_path: pathlib.Path,
233
+ tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
234
+ destroy_params: DestroyParams | None = None,
235
+ ) -> Result:
236
+ """Run terraform destroy.
237
+
238
+ *module_path* is the folder containing the Terraform files (used as
239
+ working directory). If *destroy_params.auto_approve* is true, pass
240
+ -auto-approve. *tfvars_path* is optional; when set, pass -var-file for
241
+ each path.
242
+ """
243
+ command_params = command_params.require_cwd(module_path)
244
+ if destroy_params is None:
245
+ destroy_params = DestroyParams()
246
+
247
+ destroy_args = ["destroy"]
248
+ if destroy_params.auto_approve:
249
+ destroy_args.append("-auto-approve")
250
+ if tfvars_path is not None:
251
+ paths = (
252
+ [tfvars_path] if isinstance(tfvars_path, pathlib.Path) else tfvars_path
253
+ )
254
+ for p in paths:
255
+ destroy_args.extend(["-var-file", str(p)])
256
+
257
+ return command_run(
258
+ command=self.binary_path,
259
+ command_params=command_params,
260
+ args=destroy_args,
261
+ )
262
+
263
+ def output(
264
+ self,
265
+ *,
266
+ command_params: CommandParams,
267
+ module_path: pathlib.Path,
268
+ output_model: type[TerraformOutputModel],
269
+ ) -> TerraformOutputModel:
270
+ """Run terraform output -json and parse the result into a Pydantic model.
271
+
272
+ *module_path* is the folder containing the Terraform files (used as
273
+ working directory). *output_model* is the Pydantic BaseModel subclass
274
+ used to validate the outputs. The JSON produced by
275
+ `terraform output -json` is simplified to a mapping from output names
276
+ to their `value` fields before validation.
277
+ """
278
+ command_params = command_params.require_cwd(module_path)
279
+ result = command_run(
280
+ command=self.binary_path,
281
+ command_params=command_params,
282
+ args=["output", "-json"],
283
+ )
284
+
285
+ parsed_terraform_output = _TerraformCommandOutputModel.model_validate_json(
286
+ result.stdout
287
+ )
288
+ recovered_values = {
289
+ name: entry.value for name, entry in parsed_terraform_output.root.items()
290
+ }
291
+ return output_model.model_validate(recovered_values)
292
+
293
+
294
+ @contextmanager
295
+ def terraform(
296
+ *,
297
+ version: str | None = None,
298
+ binary_cache_path: pathlib.Path,
299
+ command_params: CommandParams | None = None,
300
+ module_path: pathlib.Path | None = None,
301
+ tfbackend_path: pathlib.Path | None = None,
302
+ tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
303
+ init_on_entry: bool = False,
304
+ init_params: InitParams | None = None,
305
+ apply_on_entry: bool = False,
306
+ apply_params: ApplyParams | None = None,
307
+ delete_on_exit: bool = False,
308
+ destroy_params: DestroyParams | None = None,
309
+ ) -> Iterator[_Terraform]:
310
+ """Download a Terraform binary and yield a Terraform handle.
311
+
312
+ If *init_on_entry* is true, run init after preparing the binary; requires
313
+ *command_params* and *module_path*. If *apply_on_entry* is true, run
314
+ apply after init; requires *command_params* and *module_path*.
315
+ *tfbackend_path* is optional for init and, when set, is passed as
316
+ -backend-config=<path>. *tfvars_path* is optional for apply. If
317
+ *delete_on_exit* is true, run destroy when exiting the context; requires
318
+ *command_params* and *module_path*. *tfvars_path* is optional for destroy.
319
+ *version* when None uses the bundled default Terraform version.
320
+ """
321
+ if sys.platform != "win32":
322
+ raise RuntimeError("Only implemented on Windows")
323
+
324
+ if version is None:
325
+ version = _DEFAULT_VERSION
326
+
327
+ if version not in _KNOWN_VERSIONS:
328
+ known = ", ".join(_KNOWN_VERSIONS)
329
+ raise ValueError(
330
+ "Unknown Terraform version '{}'; known versions: {}".format(
331
+ version,
332
+ known,
333
+ )
334
+ )
335
+
336
+ if init_on_entry and (command_params is None or module_path is None):
337
+ raise ValueError("init_on_entry requires command_params and module_path")
338
+ if apply_on_entry and (command_params is None or module_path is None):
339
+ raise ValueError("apply_on_entry requires command_params and module_path")
340
+ if delete_on_exit and (command_params is None or module_path is None):
341
+ raise ValueError("delete_on_exit requires command_params and module_path")
342
+
343
+ exe_name = "terraform_{}.exe".format(version.replace(".", "_"))
344
+ exe_path = binary_cache_path / exe_name
345
+
346
+ if not exe_path.exists():
347
+ binary_cache_path.mkdir(parents=True, exist_ok=True)
348
+ try:
349
+ url = _TERRAFORM_URL_TEMPLATE.format(version=version)
350
+ response = cast(HTTPResponse, urllib.request.urlopen(url))
351
+ with response:
352
+ zip_bytes: bytes = response.read()
353
+
354
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
355
+ _ = exe_path.write_bytes(zf.read("terraform.exe"))
356
+ except Exception:
357
+ exe_path.unlink(missing_ok=True)
358
+ raise
359
+
360
+ if not exe_path.exists():
361
+ raise RuntimeError(
362
+ "Terraform executable 'terraform.exe' not found in '{}'".format(
363
+ binary_cache_path
364
+ )
365
+ )
366
+
367
+ tf = _Terraform(version=version, path=exe_path)
368
+
369
+ if init_on_entry:
370
+ assert command_params is not None
371
+ assert module_path is not None
372
+ _ = tf.init(
373
+ command_params=command_params,
374
+ module_path=module_path,
375
+ tfbackend_path=tfbackend_path,
376
+ init_params=init_params,
377
+ )
378
+
379
+ if apply_on_entry:
380
+ assert command_params is not None
381
+ assert module_path is not None
382
+ _ = tf.apply(
383
+ command_params=command_params,
384
+ module_path=module_path,
385
+ tfvars_path=tfvars_path,
386
+ apply_params=apply_params,
387
+ )
388
+
389
+ try:
390
+ yield tf
391
+ finally:
392
+ if delete_on_exit:
393
+ assert command_params is not None
394
+ assert module_path is not None
395
+ _ = tf.destroy(
396
+ command_params=command_params,
397
+ module_path=module_path,
398
+ tfvars_path=tfvars_path,
399
+ destroy_params=destroy_params,
400
+ )
401
+
402
+
403
+ @contextmanager
404
+ def terraform_output(
405
+ *,
406
+ version: str | None = None,
407
+ binary_cache_path: pathlib.Path,
408
+ command_params: CommandParams,
409
+ module_path: pathlib.Path,
410
+ tfbackend_path: pathlib.Path | None = None,
411
+ tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
412
+ init_on_entry: bool = False,
413
+ init_params: InitParams | None = None,
414
+ apply_on_entry: bool = False,
415
+ apply_params: ApplyParams | None = None,
416
+ delete_on_exit: bool = False,
417
+ destroy_params: DestroyParams | None = None,
418
+ output_model: type[TerraformOutputModel],
419
+ ) -> Iterator[TerraformOutputModel]:
420
+ """Run the terraform context manager, call output() internally, and yield the parsed result.
421
+
422
+ All entry/exit parameters are passed through to terraform(); the caller
423
+ sets *init_on_entry*, *apply_on_entry*, and *delete_on_exit* as needed.
424
+ Yields the output model; destroy runs on exit when *delete_on_exit* is true.
425
+ *version* when None uses the bundled default Terraform version.
426
+ """
427
+ with terraform(
428
+ version=version,
429
+ binary_cache_path=binary_cache_path,
430
+ command_params=command_params,
431
+ module_path=module_path,
432
+ tfbackend_path=tfbackend_path,
433
+ tfvars_path=tfvars_path,
434
+ init_on_entry=init_on_entry,
435
+ init_params=init_params,
436
+ apply_on_entry=apply_on_entry,
437
+ apply_params=apply_params,
438
+ delete_on_exit=delete_on_exit,
439
+ destroy_params=destroy_params,
440
+ ) as tf:
441
+ yield tf.output(
442
+ command_params=command_params,
443
+ module_path=module_path,
444
+ output_model=output_model,
445
+ )
File without changes