fogies 0.0.0.dev1__tar.gz → 0.0.0.dev2__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.dev2
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.2"
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 = "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,7 +39,7 @@ semver = "^3.0.4"
30
39
  [tool.basedpyright]
31
40
  typeCheckingMode = "recommended"
32
41
  pythonVersion = "3.14"
33
- include = ["src", "tasks", "tests"]
42
+ include = ["paths.py", "src", "tasks", "tests"]
34
43
  allowedUntypedLibraries = ["invoke"]
35
44
 
36
45
  [build-system]
@@ -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,79 @@
1
+ """Helpers for configuring AWS-related environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from fogies.tools.environ import environ
11
+
12
+
13
+ class _AwsProfile(BaseModel):
14
+ aws_access_key_id: str
15
+ aws_secret_access_key: str
16
+
17
+
18
+ def _load_aws_profile_from_toml(profiles_path: Path, profile: str) -> _AwsProfile:
19
+ """Return AWS profile loaded from a TOML profiles file.
20
+
21
+ The file is expected to contain a table for each profile, for example:
22
+
23
+ [test]
24
+ aws_access_key_id = "value-id"
25
+ aws_secret_access_key = "value-secret"
26
+ """
27
+ if profiles_path.suffix != ".toml":
28
+ raise ValueError(
29
+ "AWS profiles file must have .toml extension, got '{}'".format(
30
+ profiles_path
31
+ )
32
+ )
33
+ if not profiles_path.exists():
34
+ raise FileNotFoundError(
35
+ "AWS profiles file '{}' does not exist".format(profiles_path)
36
+ )
37
+
38
+ with profiles_path.open("rb") as profiles_file:
39
+ data: dict[str, object] = tomllib.load(profiles_file)
40
+
41
+ try:
42
+ profile_raw = data[profile]
43
+ except KeyError as exc:
44
+ raise KeyError(
45
+ "AWS profile '{}' not found in '{}'".format(
46
+ profile,
47
+ profiles_path,
48
+ )
49
+ ) from exc
50
+
51
+ return _AwsProfile.model_validate(profile_raw)
52
+
53
+
54
+ def aws_environ(
55
+ profiles_path: Path,
56
+ profile: str,
57
+ *,
58
+ raise_if_exists: bool = True,
59
+ raise_if_changed: bool = True,
60
+ ):
61
+ """Return a context manager that applies AWS variables from a TOML file.
62
+
63
+ The *profiles_path* parameter specifies the AWS TOML profiles file to read;
64
+ it must have a ``.toml`` extension. The *profile* parameter specifies the
65
+ AWS profile name, which is mapped to a ``[<name>]`` table in the profiles
66
+ file.
67
+ """
68
+ aws_profile = _load_aws_profile_from_toml(
69
+ profiles_path=profiles_path, profile=profile
70
+ )
71
+ variables: dict[str, str] = {
72
+ "AWS_ACCESS_KEY_ID": aws_profile.aws_access_key_id,
73
+ "AWS_SECRET_ACCESS_KEY": aws_profile.aws_secret_access_key,
74
+ }
75
+ return environ(
76
+ variables=variables,
77
+ raise_if_exists=raise_if_exists,
78
+ raise_if_changed=raise_if_changed,
79
+ )
@@ -0,0 +1,78 @@
1
+ import dataclasses
2
+ import os
3
+ import pathlib
4
+ from typing import cast
5
+
6
+ from invoke.context import Context
7
+ from invoke.runners import Result
8
+
9
+
10
+ @dataclasses.dataclass(frozen=True, slots=True)
11
+ class CommandParams:
12
+ """Params for execution via invoke."""
13
+
14
+ context: Context | None = None
15
+ cwd: pathlib.Path | None = None
16
+ in_stream: bool = True
17
+
18
+ def require_cwd(self, path: pathlib.Path) -> "CommandParams":
19
+ """Ensure cwd is set to *path*; return updated params or raise if conflicting."""
20
+ if self.cwd is None:
21
+ return dataclasses.replace(self, cwd=path)
22
+ if self.cwd == path:
23
+ return self
24
+ raise ValueError(
25
+ "CommandParams requires cwd '{}' but already has '{}'".format(
26
+ path,
27
+ self.cwd,
28
+ )
29
+ )
30
+
31
+
32
+ def _resolve_command(
33
+ command: pathlib.Path,
34
+ cwd: pathlib.Path | None,
35
+ ) -> str:
36
+ """Return the command string to pass to the shell.
37
+
38
+ When *cwd* is set and *command* resolves to an existing file (a specific
39
+ binary), returns a path relative to *cwd*. Otherwise returns the command as
40
+ given so a name like \"bash\" is left for PATH (e.g. shutil.which).
41
+ """
42
+ if cwd is not None:
43
+ resolved = command.resolve()
44
+ if resolved.exists():
45
+ return os.path.relpath(resolved, cwd.resolve())
46
+ return str(command)
47
+
48
+
49
+ def command_run(
50
+ *,
51
+ command: pathlib.Path,
52
+ command_params: CommandParams | None = None,
53
+ args: list[str] | None = None,
54
+ ) -> Result:
55
+ """Run a command via invoke run().
56
+
57
+ If *context* is provided, use context.run(). Otherwise create a new
58
+ Context and run the command there.
59
+ """
60
+ command_params = command_params or CommandParams()
61
+ context = command_params.context or Context()
62
+
63
+ resolved_command = _resolve_command(command, command_params.cwd)
64
+ args_combined = [resolved_command] + (args or [])
65
+ command_str = " ".join(args_combined)
66
+
67
+ if command_params.cwd is not None:
68
+ context_cd = context.cd( # pyright: ignore[reportUnknownMemberType]
69
+ str(command_params.cwd)
70
+ )
71
+ with context_cd:
72
+ result = context.run(command_str, in_stream=command_params.in_stream)
73
+ else:
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,107 @@
1
+ """Context managers for temporary environment variable overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+
9
+ class _EnvironContext:
10
+ """Context manager for environment variable overrides."""
11
+
12
+ def __init__(
13
+ self,
14
+ variables: Mapping[str, str],
15
+ *,
16
+ raise_if_exists: bool,
17
+ raise_if_changed: bool,
18
+ ) -> None:
19
+ self._variables: dict[str, str] = {
20
+ name: str(value) for name, value in variables.items()
21
+ }
22
+ self._raise_if_exists: bool = raise_if_exists
23
+ self._raise_if_changed: bool = raise_if_changed
24
+ self._original_variables: dict[str, str | None] = {}
25
+ self._applied_variables: list[str] = []
26
+
27
+ def _restore_originals(self) -> None:
28
+ """Restore each applied variable to its original value or remove it."""
29
+ for name in reversed(self._applied_variables):
30
+ original = self._original_variables.get(name)
31
+ if original is None:
32
+ if name in os.environ:
33
+ del os.environ[name]
34
+ else:
35
+ os.environ[name] = original
36
+
37
+ def __enter__(self) -> None:
38
+ try:
39
+ for name, value in self._variables.items():
40
+ existing = os.environ.get(name)
41
+ if existing is not None and self._raise_if_exists:
42
+ raise ValueError(
43
+ "Environment variable '{}' already exists".format(name)
44
+ )
45
+
46
+ self._original_variables[name] = existing
47
+ os.environ[name] = value
48
+ self._applied_variables.append(name)
49
+ except Exception:
50
+ # Roll back any changes made before the failure.
51
+ self._restore_originals()
52
+ raise
53
+ return None
54
+
55
+ def __exit__(
56
+ self,
57
+ exc_type: type[BaseException] | None,
58
+ exc: BaseException | None,
59
+ exc_tb: object | None,
60
+ ) -> bool:
61
+ error: RuntimeError | None = None
62
+
63
+ # Enforce raise_if_changed only when the body exited normally.
64
+ if exc_type is None and self._raise_if_changed:
65
+ for name in self._applied_variables:
66
+ expected = self._variables[name]
67
+ current = os.environ.get(name)
68
+ if current != expected:
69
+ error = RuntimeError(
70
+ "Environment variable '{}' was modified while context manager was active".format(
71
+ name
72
+ )
73
+ )
74
+ break
75
+
76
+ # Always restore original values.
77
+ self._restore_originals()
78
+
79
+ if error is not None:
80
+ raise error
81
+
82
+ return False
83
+
84
+
85
+ def environ(
86
+ variables: Mapping[str, str],
87
+ *,
88
+ raise_if_exists: bool = True,
89
+ raise_if_changed: bool = True,
90
+ ) -> _EnvironContext:
91
+ """Return a context manager that applies the given environment overrides.
92
+
93
+ The *variables* mapping provides environment variable names and string values
94
+ to assign for the duration of the context.
95
+
96
+ For each variable:
97
+ - If raise_if_exists is True (default) and the variable already exists,
98
+ raises ValueError and leaves the environment unchanged.
99
+ - On normal exit, if raise_if_changed is True (default) and the value in
100
+ the environment differs from the value set by this context manager,
101
+ raises RuntimeError.
102
+ """
103
+ return _EnvironContext(
104
+ variables=variables,
105
+ raise_if_exists=raise_if_exists,
106
+ raise_if_changed=raise_if_changed,
107
+ )
@@ -0,0 +1,329 @@
1
+ import io
2
+ import pathlib
3
+ import socket
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ import urllib.request
8
+ import zipfile
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from http.client import HTTPResponse
12
+ from typing import cast
13
+
14
+ import ollama as _ollama_client
15
+ import psutil
16
+ from filelock import BaseFileLock, FileLock
17
+ from pydantic import BaseModel
18
+
19
+ _KNOWN_VERSIONS = [
20
+ "0.17.7",
21
+ ]
22
+
23
+ _DEFAULT_VERSION = _KNOWN_VERSIONS[-1]
24
+
25
+ _OLLAMA_URL_TEMPLATE = (
26
+ "https://github.com/ollama/ollama/releases/download"
27
+ "/v{version}/ollama-windows-amd64.zip"
28
+ )
29
+
30
+ _OLLAMA_LISTEN_ADDRESS: tuple[str, int] = ("127.0.0.1", 11434)
31
+ _OLLAMA_LISTEN_PROBE_TIMEOUT = 0.25
32
+ _OLLAMA_LISTEN_POLL_INTERVAL = 0.1
33
+ _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT = 10.0
34
+ _OLLAMA_TERMINATE_WAIT_TIMEOUT = 10.0
35
+
36
+
37
+ class _PidWithCreateTime(BaseModel):
38
+ """Persisted process identity state."""
39
+
40
+ pid: int
41
+ create_time: float | None
42
+
43
+
44
+ class _Ollama:
45
+ """Represents an Ollama CLI binary."""
46
+
47
+ _version: str
48
+ _path: pathlib.Path
49
+ _file_lock: BaseFileLock
50
+
51
+ def __init__(self, *, version: str, path: pathlib.Path) -> None:
52
+ self._version = version
53
+ self._path = path
54
+ self._file_lock = FileLock(str(self._lock_path), is_singleton=True)
55
+
56
+ @property
57
+ def binary_version(self) -> str:
58
+ """The Ollama binary version string."""
59
+ return self._version
60
+
61
+ @property
62
+ def binary_path(self) -> pathlib.Path:
63
+ """The path to the Ollama executable."""
64
+ return self._path
65
+
66
+ @property
67
+ def _pid_path(self) -> pathlib.Path:
68
+ """Return the PID file path for this binary."""
69
+ return self._path.with_suffix(".pid")
70
+
71
+ @property
72
+ def _refcount_path(self) -> pathlib.Path:
73
+ """Return the refcount file path for this binary."""
74
+ return self._path.with_suffix(".refcount")
75
+
76
+ @property
77
+ def _lock_path(self) -> pathlib.Path:
78
+ """Return the lock file path for this binary."""
79
+ return self._path.with_suffix(".lock")
80
+
81
+ @property
82
+ def pid(self) -> _PidWithCreateTime | None:
83
+ """Return process identity state, or None if not managed."""
84
+ self._assert_lock()
85
+
86
+ pid_path = self._pid_path
87
+ if not pid_path.exists():
88
+ return None
89
+
90
+ text = pid_path.read_text(encoding="utf-8").strip()
91
+ assert text is not None
92
+
93
+ state = _PidWithCreateTime.model_validate_json(text)
94
+ assert state.pid > 0
95
+
96
+ return state
97
+
98
+ @property
99
+ def refcount(self) -> int:
100
+ """Return the server refcount, or 0 if not managed."""
101
+ self._assert_lock()
102
+
103
+ refcount_path = self._refcount_path
104
+ if not refcount_path.exists():
105
+ return 0
106
+
107
+ text = refcount_path.read_text(encoding="utf-8").strip()
108
+ if not text:
109
+ return 0
110
+
111
+ return int(text)
112
+
113
+ @contextmanager
114
+ def lock(self) -> Iterator[None]:
115
+ """Acquire and hold the file lock."""
116
+ with self._file_lock:
117
+ yield
118
+
119
+ def _assert_lock(self) -> None:
120
+ assert (
121
+ self._file_lock.is_locked
122
+ ), "Must be accessed within an Ollama lock context."
123
+
124
+ def reset_state(self) -> None:
125
+ """Clear the server state files."""
126
+ self._assert_lock()
127
+
128
+ self._pid_path.unlink(missing_ok=True)
129
+ self._refcount_path.unlink(missing_ok=True)
130
+
131
+ def set_pid(self, pid: _PidWithCreateTime) -> None:
132
+ """Set the managed server PID state."""
133
+ self._assert_lock()
134
+
135
+ pid_path = self._pid_path
136
+
137
+ text = "{}\n".format(pid.model_dump_json())
138
+ _ = pid_path.write_text(text, encoding="utf-8")
139
+
140
+ def set_refcount(self, count: int) -> None:
141
+ """Set the managed server refcount state."""
142
+ self._assert_lock()
143
+
144
+ refcount_path = self._refcount_path
145
+
146
+ _ = refcount_path.write_text(
147
+ "{}\n".format(count),
148
+ encoding="utf-8",
149
+ )
150
+
151
+
152
+ def _terminate_pid(*, pid: _PidWithCreateTime) -> None:
153
+ try:
154
+ process = psutil.Process(pid.pid)
155
+
156
+ # If the process has been restarted, the pid was reused, do not terminate it.
157
+ if pid.create_time is not None and process.create_time() != pid.create_time:
158
+ return
159
+
160
+ children = process.children(recursive=True)
161
+ processes = [*children, process]
162
+
163
+ for process_to_end in processes:
164
+ try:
165
+ process_to_end.terminate()
166
+ except psutil.Error:
167
+ pass
168
+
169
+ _, alive = psutil.wait_procs(
170
+ processes,
171
+ timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
172
+ )
173
+ if not alive:
174
+ return
175
+
176
+ for process_to_kill in alive:
177
+ try:
178
+ process_to_kill.kill()
179
+ except psutil.Error:
180
+ pass
181
+
182
+ _ = psutil.wait_procs(
183
+ alive,
184
+ timeout=_OLLAMA_TERMINATE_WAIT_TIMEOUT,
185
+ )
186
+ except psutil.Error:
187
+ pass
188
+
189
+
190
+ def _wait_until_listening(
191
+ *,
192
+ timeout_s: float = _OLLAMA_LISTEN_PROBE_WAIT_TIMEOUT,
193
+ ) -> None:
194
+ end = time.time() + timeout_s
195
+ while time.time() < end:
196
+ try:
197
+ with socket.create_connection(
198
+ _OLLAMA_LISTEN_ADDRESS,
199
+ timeout=_OLLAMA_LISTEN_PROBE_TIMEOUT,
200
+ ):
201
+ return
202
+ except OSError:
203
+ pass
204
+ time.sleep(_OLLAMA_LISTEN_POLL_INTERVAL)
205
+ raise TimeoutError("Ollama server did not become ready in time")
206
+
207
+
208
+ @contextmanager
209
+ def ollama(
210
+ *,
211
+ version: str | None = None,
212
+ binary_cache_path: pathlib.Path,
213
+ ) -> Iterator[_Ollama]:
214
+ """Download an Ollama Windows CLI release and yield an Ollama handle.
215
+
216
+ *version* is the Ollama release tag version (e.g., "0.17.7"). The archive is
217
+ downloaded from the GitHub releases page if it does not already exist in
218
+ *binary_cache_path*. The CLI zip archive `ollama-windows-amd64.zip` is
219
+ fetched and the full folder structure is extracted into a versioned
220
+ directory inside *binary_cache_path* and used from there.
221
+ """
222
+ if sys.platform != "win32":
223
+ raise RuntimeError("Only implemented on Windows")
224
+
225
+ if version is None:
226
+ version = _DEFAULT_VERSION
227
+
228
+ if version not in _KNOWN_VERSIONS:
229
+ known = ", ".join(_KNOWN_VERSIONS)
230
+ raise ValueError(
231
+ "Unknown Ollama version '{}'; known versions: {}".format(
232
+ version,
233
+ known,
234
+ )
235
+ )
236
+
237
+ dir_name = "ollama_{}".format(version.replace(".", "_"))
238
+ version_dir = binary_cache_path / dir_name
239
+
240
+ if not version_dir.exists():
241
+ version_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ url = _OLLAMA_URL_TEMPLATE.format(version=version)
244
+ response = cast(HTTPResponse, urllib.request.urlopen(url))
245
+ with response:
246
+ zip_bytes: bytes = response.read()
247
+
248
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
249
+ zf.extractall(version_dir)
250
+
251
+ exe_path = version_dir / "ollama.exe"
252
+ if not exe_path.exists():
253
+ raise RuntimeError(
254
+ "Ollama executable 'ollama.exe' not found in '{}'".format(version_dir)
255
+ )
256
+
257
+ yield _Ollama(version=version, path=exe_path)
258
+
259
+
260
+ @contextmanager
261
+ def ollama_client(
262
+ *,
263
+ version: str | None = None,
264
+ binary_cache_path: pathlib.Path,
265
+ show_window: bool = False,
266
+ ) -> Iterator[_ollama_client.Client]:
267
+ """Provide an Ollama Python client with a running local server.
268
+
269
+ Starts Ollama server in background and yields a configured
270
+ :class:`ollama.Client` instance connected to default local host.
271
+ Set *show_window* to True to allow a visible console window.
272
+ """
273
+ with ollama(version=version, binary_cache_path=binary_cache_path) as ollama_binary:
274
+ with ollama_binary.lock():
275
+ running = False
276
+
277
+ # First check whether a process is already running.
278
+ pid = ollama_binary.pid
279
+ if pid is not None and pid.create_time is not None:
280
+ try:
281
+ proc = psutil.Process(pid.pid)
282
+ running = (
283
+ proc.is_running()
284
+ and proc.status() != psutil.STATUS_ZOMBIE
285
+ and proc.create_time() == pid.create_time
286
+ )
287
+ except psutil.Error:
288
+ running = False
289
+
290
+ # If needed, start the server.
291
+ if not running:
292
+ ollama_binary.reset_state()
293
+
294
+ stdout = None if show_window else subprocess.DEVNULL
295
+ stderr = None if show_window else subprocess.DEVNULL
296
+ creation_flags = subprocess.CREATE_NEW_CONSOLE if show_window else 0
297
+
298
+ server_process = subprocess.Popen(
299
+ [str(ollama_binary.binary_path), "serve"],
300
+ stdout=stdout,
301
+ stderr=stderr,
302
+ creationflags=creation_flags,
303
+ )
304
+ ollama_binary.set_pid(
305
+ _PidWithCreateTime(
306
+ pid=server_process.pid,
307
+ create_time=psutil.Process(server_process.pid).create_time(),
308
+ )
309
+ )
310
+
311
+ # Increment the refcount.
312
+ ollama_binary.set_refcount(ollama_binary.refcount + 1)
313
+
314
+ try:
315
+ # Wait until the server is listening.
316
+ _wait_until_listening()
317
+
318
+ # Create the client.
319
+ client = _ollama_client.Client()
320
+ yield client
321
+ finally:
322
+ with ollama_binary.lock():
323
+ ollama_binary.set_refcount(ollama_binary.refcount - 1)
324
+
325
+ if ollama_binary.refcount <= 0:
326
+ pid = ollama_binary.pid
327
+ if pid is not None:
328
+ _terminate_pid(pid=pid)
329
+ ollama_binary.reset_state()
@@ -0,0 +1,435 @@
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") 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") 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
+
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
+
357
+ tf = _Terraform(version=version, path=exe_path)
358
+
359
+ if init_on_entry:
360
+ assert command_params is not None
361
+ assert module_path is not None
362
+ _ = tf.init(
363
+ command_params=command_params,
364
+ module_path=module_path,
365
+ tfbackend_path=tfbackend_path,
366
+ init_params=init_params,
367
+ )
368
+
369
+ if apply_on_entry:
370
+ assert command_params is not None
371
+ assert module_path is not None
372
+ _ = tf.apply(
373
+ command_params=command_params,
374
+ module_path=module_path,
375
+ tfvars_path=tfvars_path,
376
+ apply_params=apply_params,
377
+ )
378
+
379
+ try:
380
+ yield tf
381
+ finally:
382
+ if delete_on_exit:
383
+ assert command_params is not None
384
+ assert module_path is not None
385
+ _ = tf.destroy(
386
+ command_params=command_params,
387
+ module_path=module_path,
388
+ tfvars_path=tfvars_path,
389
+ destroy_params=destroy_params,
390
+ )
391
+
392
+
393
+ @contextmanager
394
+ def terraform_output(
395
+ *,
396
+ version: str | None = None,
397
+ binary_cache_path: pathlib.Path,
398
+ command_params: CommandParams,
399
+ module_path: pathlib.Path,
400
+ tfbackend_path: pathlib.Path | None = None,
401
+ tfvars_path: pathlib.Path | list[pathlib.Path] | None = None,
402
+ init_on_entry: bool = False,
403
+ init_params: InitParams | None = None,
404
+ apply_on_entry: bool = False,
405
+ apply_params: ApplyParams | None = None,
406
+ delete_on_exit: bool = False,
407
+ destroy_params: DestroyParams | None = None,
408
+ output_model: type[TerraformOutputModel],
409
+ ) -> Iterator[TerraformOutputModel]:
410
+ """Run the terraform context manager, call output() internally, and yield the parsed result.
411
+
412
+ All entry/exit parameters are passed through to terraform(); the caller
413
+ sets *init_on_entry*, *apply_on_entry*, and *delete_on_exit* as needed.
414
+ Yields the output model; destroy runs on exit when *delete_on_exit* is true.
415
+ *version* when None uses the bundled default Terraform version.
416
+ """
417
+ with terraform(
418
+ version=version,
419
+ binary_cache_path=binary_cache_path,
420
+ command_params=command_params,
421
+ module_path=module_path,
422
+ tfbackend_path=tfbackend_path,
423
+ tfvars_path=tfvars_path,
424
+ init_on_entry=init_on_entry,
425
+ init_params=init_params,
426
+ apply_on_entry=apply_on_entry,
427
+ apply_params=apply_params,
428
+ delete_on_exit=delete_on_exit,
429
+ destroy_params=destroy_params,
430
+ ) as tf:
431
+ yield tf.output(
432
+ command_params=command_params,
433
+ module_path=module_path,
434
+ output_model=output_model,
435
+ )
File without changes