aiscript 1.2.0__tar.gz → 1.3.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiscript
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM.
5
5
  Author: Daniel Illescas Romero
6
6
  Author-email: Daniel Illescas Romero <illescas.daniel@protonmail.com>
@@ -39,6 +39,20 @@ pip install aiscript
39
39
 
40
40
  Requires Python 3.10+.
41
41
 
42
+ On Linux or macOS, check that the installed command is on your `PATH`:
43
+
44
+ ```sh
45
+ command -v aiscript
46
+ ```
47
+
48
+ If it is not found, use your installer's PATH setup: `uv tool update-shell` for uv, or
49
+ `pipx ensurepath` for pipx. These commands update shell configuration when you run them; open a
50
+ new terminal for the changes to take effect. With pip, add the directory containing its installed
51
+ `aiscript` executable to your PATH using your shell's configuration. See the
52
+ [uv tool documentation](https://docs.astral.sh/uv/concepts/tools/) and
53
+ [pipx installation guide](https://pipx.pypa.io/latest/how-to/install-pipx.html) for details.
54
+ Installing aiscript itself does not edit shell profiles.
55
+
42
56
  To upgrade to the latest version, use the same tool you installed with:
43
57
 
44
58
  ```sh
@@ -80,6 +94,46 @@ aiscript sizes ~/Downloads --top 5 # later runs: cached, arguments passed stra
80
94
  Everything after the command name is passed untouched to the script (including `--help`).
81
95
  `aiscript` messages go to stderr; stdout belongs to the script, and the exit code is the script's.
82
96
 
97
+ ## Executable prompt files
98
+
99
+ On Linux and macOS, write a UTF-8 file named `biggest`:
100
+
101
+ ```text
102
+ #!/usr/bin/env aiscript
103
+ List the largest folders inside the directory given as the first argument.
104
+ ```
105
+
106
+ Make it executable and run it with a directory:
107
+
108
+ ```sh
109
+ chmod +x biggest
110
+ ./biggest ~/Downloads
111
+ ```
112
+
113
+ `/usr/bin/env` finds `aiscript` on `PATH`. Alternatively, use its absolute path in the shebang,
114
+ for example `#!/home/alex/.local/bin/aiscript`. The operating system does not expand `$HOME` or
115
+ `~` in a shebang.
116
+
117
+ You can also run a prompt file directly with `aiscript ./biggest ~/Downloads`; a shebang is
118
+ optional in this form. On Windows, use direct invocation with an explicit path, for example
119
+ `aiscript .\biggest Downloads`; Unix shebang execution is not supported there. The path must
120
+ include a path separator: a bare name such as `biggest` selects the existing named-command
121
+ workflow, even when a file with that name exists. Quote paths or arguments containing spaces.
122
+ Arguments after the path, including flags, are passed through unchanged, as are the program's
123
+ standard input, output and exit status.
124
+
125
+ The first run generates and reviews a script using the same safety checks as named commands.
126
+ Risky builds require approval and are refused when standard input is non-interactive. Later
127
+ runs reuse a matching reviewed build without calling a model; changing the prompt rebuilds and
128
+ reviews it. A changed shebang alone does not trigger a rebuild. If rebuilding fails or is
129
+ rejected, aiscript keeps the old build but does not execute it for the changed prompt.
130
+ File builds are stored separately from named commands under `~/.config/aiscript/file-cache/`
131
+ (or `$XDG_CONFIG_HOME/aiscript/file-cache/`, or `$AISCRIPT_HOME/file-cache/`). Paths resolving
132
+ to the same file share a cache; moving the file creates a new entry. Historical versions are
133
+ retained. To reclaim their space, remove the file-cache directory only when no prompt-file
134
+ invocation is using it; the next run will rebuild and review its script. Named-command
135
+ management options such as `--remove` do not manage this cache.
136
+
83
137
  ## Safety review / decision models
84
138
 
85
139
  Every generated script is reviewed **before it is saved**:
@@ -165,6 +219,7 @@ and [safety](docs/safety.md#openai-decisions) for the full contract.
165
219
  | Command | Action |
166
220
  |---|---|
167
221
  | `aiscript NAME [args...]` | Run a command; created and built on first use |
222
+ | `aiscript PATH [args...]` | Run a prompt file using an explicit path |
168
223
  | `aiscript --init` | Create config dirs and a `config.yaml` template (never overwrites) |
169
224
  | `aiscript --list` | Table of commands, descriptions and usage (`*` = prompt changed, will rebuild) |
170
225
  | `aiscript --edit NAME` | Open the prompt in `$VISUAL` / `$EDITOR` (fallback nano/vi, notepad on Windows) |
@@ -199,6 +254,7 @@ why in the script header). `aiscript --list` shows each command's language.
199
254
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
200
255
  ├── config.yaml # providers and keys
201
256
  ├── prompts/<name>.txt # your intent for each command
257
+ ├── file-cache/ # reviewed builds for prompt files
202
258
  └── functions/
203
259
  ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
204
260
  ├── <name>.py # standard-library Python
@@ -25,6 +25,20 @@ pip install aiscript
25
25
 
26
26
  Requires Python 3.10+.
27
27
 
28
+ On Linux or macOS, check that the installed command is on your `PATH`:
29
+
30
+ ```sh
31
+ command -v aiscript
32
+ ```
33
+
34
+ If it is not found, use your installer's PATH setup: `uv tool update-shell` for uv, or
35
+ `pipx ensurepath` for pipx. These commands update shell configuration when you run them; open a
36
+ new terminal for the changes to take effect. With pip, add the directory containing its installed
37
+ `aiscript` executable to your PATH using your shell's configuration. See the
38
+ [uv tool documentation](https://docs.astral.sh/uv/concepts/tools/) and
39
+ [pipx installation guide](https://pipx.pypa.io/latest/how-to/install-pipx.html) for details.
40
+ Installing aiscript itself does not edit shell profiles.
41
+
28
42
  To upgrade to the latest version, use the same tool you installed with:
29
43
 
30
44
  ```sh
@@ -66,6 +80,46 @@ aiscript sizes ~/Downloads --top 5 # later runs: cached, arguments passed stra
66
80
  Everything after the command name is passed untouched to the script (including `--help`).
67
81
  `aiscript` messages go to stderr; stdout belongs to the script, and the exit code is the script's.
68
82
 
83
+ ## Executable prompt files
84
+
85
+ On Linux and macOS, write a UTF-8 file named `biggest`:
86
+
87
+ ```text
88
+ #!/usr/bin/env aiscript
89
+ List the largest folders inside the directory given as the first argument.
90
+ ```
91
+
92
+ Make it executable and run it with a directory:
93
+
94
+ ```sh
95
+ chmod +x biggest
96
+ ./biggest ~/Downloads
97
+ ```
98
+
99
+ `/usr/bin/env` finds `aiscript` on `PATH`. Alternatively, use its absolute path in the shebang,
100
+ for example `#!/home/alex/.local/bin/aiscript`. The operating system does not expand `$HOME` or
101
+ `~` in a shebang.
102
+
103
+ You can also run a prompt file directly with `aiscript ./biggest ~/Downloads`; a shebang is
104
+ optional in this form. On Windows, use direct invocation with an explicit path, for example
105
+ `aiscript .\biggest Downloads`; Unix shebang execution is not supported there. The path must
106
+ include a path separator: a bare name such as `biggest` selects the existing named-command
107
+ workflow, even when a file with that name exists. Quote paths or arguments containing spaces.
108
+ Arguments after the path, including flags, are passed through unchanged, as are the program's
109
+ standard input, output and exit status.
110
+
111
+ The first run generates and reviews a script using the same safety checks as named commands.
112
+ Risky builds require approval and are refused when standard input is non-interactive. Later
113
+ runs reuse a matching reviewed build without calling a model; changing the prompt rebuilds and
114
+ reviews it. A changed shebang alone does not trigger a rebuild. If rebuilding fails or is
115
+ rejected, aiscript keeps the old build but does not execute it for the changed prompt.
116
+ File builds are stored separately from named commands under `~/.config/aiscript/file-cache/`
117
+ (or `$XDG_CONFIG_HOME/aiscript/file-cache/`, or `$AISCRIPT_HOME/file-cache/`). Paths resolving
118
+ to the same file share a cache; moving the file creates a new entry. Historical versions are
119
+ retained. To reclaim their space, remove the file-cache directory only when no prompt-file
120
+ invocation is using it; the next run will rebuild and review its script. Named-command
121
+ management options such as `--remove` do not manage this cache.
122
+
69
123
  ## Safety review / decision models
70
124
 
71
125
  Every generated script is reviewed **before it is saved**:
@@ -151,6 +205,7 @@ and [safety](docs/safety.md#openai-decisions) for the full contract.
151
205
  | Command | Action |
152
206
  |---|---|
153
207
  | `aiscript NAME [args...]` | Run a command; created and built on first use |
208
+ | `aiscript PATH [args...]` | Run a prompt file using an explicit path |
154
209
  | `aiscript --init` | Create config dirs and a `config.yaml` template (never overwrites) |
155
210
  | `aiscript --list` | Table of commands, descriptions and usage (`*` = prompt changed, will rebuild) |
156
211
  | `aiscript --edit NAME` | Open the prompt in `$VISUAL` / `$EDITOR` (fallback nano/vi, notepad on Windows) |
@@ -185,6 +240,7 @@ why in the script header). `aiscript --list` shows each command's language.
185
240
  ~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
186
241
  ├── config.yaml # providers and keys
187
242
  ├── prompts/<name>.txt # your intent for each command
243
+ ├── file-cache/ # reviewed builds for prompt files
188
244
  └── functions/
189
245
  ├── <name>.sh # generated, reviewed scripts (.ps1 on Windows)
190
246
  ├── <name>.py # standard-library Python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiscript"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "JIT-compile plain-English intents into cached, reviewed shell or Python scripts via an LLM."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """aiscript: JIT-compile plain-English intents into cached shell scripts."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -38,16 +38,21 @@ def _read_intent(name: str) -> str:
38
38
  return intent
39
39
 
40
40
 
41
- def build(name: str, settings: Settings) -> Path:
42
- """Generate, review and atomically write the script for ``name``."""
43
- intent = _read_intent(name)
41
+ def generate_reviewed(name: str, intent: str, settings: Settings) -> llm.GeneratedScript:
42
+ """Generate a program and require the existing safety review before returning it."""
44
43
  gen = settings.generator
45
44
  print(f"Generating {name!r} with {gen.name}/{gen.model}...", file=sys.stderr)
46
45
  script = llm.generate_script(gen, name, intent)
47
-
48
46
  result = reviewer.review(script.code, script.language, settings.reviewer, script.dependencies)
49
47
  if not reviewer.confirm(script.code, result):
50
48
  raise AiscriptError("Script rejected; nothing saved.")
49
+ return script
50
+
51
+
52
+ def build(name: str, settings: Settings) -> Path:
53
+ """Generate, review and atomically write the script for ``name``."""
54
+ intent = _read_intent(name)
55
+ script = generate_reviewed(name, intent, settings)
51
56
 
52
57
  paths.ensure_dirs()
53
58
  if script.dependencies:
@@ -8,8 +8,9 @@ import shlex
8
8
  import shutil
9
9
  import subprocess
10
10
  import sys
11
+ from pathlib import Path
11
12
 
12
- from aiscript import __version__, builder, paths
13
+ from aiscript import __version__, builder, paths, prompt_file
13
14
  from aiscript.config import load_settings
14
15
  from aiscript.errors import AiscriptError
15
16
  from aiscript.init_cmd import run_init
@@ -18,6 +19,7 @@ from aiscript.runner import run_script
18
19
 
19
20
  _HELP = """\
20
21
  usage: aiscript NAME [args...] run a command (created/built on first use)
22
+ aiscript PATH [args...] run a prompt file specified by path
21
23
  aiscript --init create config dir and config.yaml template
22
24
  aiscript --list list commands
23
25
  aiscript --edit NAME edit a command's prompt (rebuilds on next run)
@@ -131,6 +133,8 @@ def _dispatch(argv: list[str]) -> int:
131
133
  if not argv:
132
134
  sys.stderr.write(_HELP)
133
135
  return 2
136
+ if os.sep in argv[0] or (os.altsep is not None and os.altsep in argv[0]):
137
+ return prompt_file.execute(Path(argv[0]), argv[1:], load_settings)
134
138
  if argv[0].startswith("-"):
135
139
  try:
136
140
  ns = _build_parser().parse_args(argv)
@@ -0,0 +1,87 @@
1
+ """Domain values and ports for executable prompt files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from contextlib import AbstractContextManager as ContextManager
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PromptSnapshot:
14
+ """A canonical source and the exact prompt content used for a build."""
15
+
16
+ source: Path
17
+ prompt: str
18
+ digest: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ReviewedBuild:
23
+ """A reviewed, immutable entrypoint associated with a prompt digest."""
24
+
25
+ prompt_digest: str
26
+ entrypoint: Path
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BuildDestination:
31
+ """A unique, permanent version directory, invisible until publication."""
32
+
33
+ version_dir: Path
34
+
35
+
36
+ class PromptSource(Protocol):
37
+ """Read a prompt file into an immutable snapshot."""
38
+
39
+ def read(self, path: Path) -> PromptSnapshot: ...
40
+
41
+
42
+ class BuildLease(Protocol):
43
+ """Exclusive per-source lease for matching and publishing builds."""
44
+
45
+ def find(self, snapshot: PromptSnapshot) -> ReviewedBuild | None:
46
+ """Find a complete reviewed build matching this snapshot."""
47
+ ...
48
+
49
+ def allocate(self) -> BuildDestination:
50
+ """Allocate a unique destination at its final path."""
51
+ ...
52
+
53
+ def publish(
54
+ self, snapshot: PromptSnapshot, destination: BuildDestination, entrypoint: Path
55
+ ) -> ReviewedBuild:
56
+ """Atomically publish the artifact and its identity together."""
57
+ ...
58
+
59
+ def discard(self, destination: BuildDestination) -> None:
60
+ """Discard an unpublished destination without affecting published versions."""
61
+ ...
62
+
63
+
64
+ class FileBuildCache(Protocol):
65
+ """Coordinate file builds independently of named-command caches."""
66
+
67
+ def acquire(self, source: Path) -> ContextManager[BuildLease]:
68
+ """Acquire an exclusive lease for a canonical source."""
69
+ ...
70
+
71
+
72
+ class PromptFileBuilder(Protocol):
73
+ """Build and review a prompt snapshot at its permanent destination."""
74
+
75
+ def build(self, snapshot: PromptSnapshot, destination: BuildDestination) -> Path: ...
76
+
77
+
78
+ class ProgramRunner(Protocol):
79
+ """Run a reviewed build with the supplied arguments."""
80
+
81
+ def run(self, build: ReviewedBuild, args: Sequence[str]) -> int: ...
82
+
83
+
84
+ class PromptFileExecution(Protocol):
85
+ """Execute a prompt file and return its program's exit status."""
86
+
87
+ def execute(self, path: Path, args: Sequence[str]) -> int: ...
@@ -0,0 +1,244 @@
1
+ """Execution and immutable reviewed cache for explicit prompt files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import tempfile
12
+ from collections.abc import Callable, Generator, Sequence
13
+ from pathlib import Path
14
+
15
+ from aiscript import builder, env, paths, pysetup
16
+ from aiscript.config import Settings
17
+ from aiscript.errors import AiscriptError
18
+ from aiscript.file_contracts import (
19
+ BuildDestination,
20
+ BuildLease,
21
+ FileBuildCache,
22
+ ProgramRunner,
23
+ PromptFileBuilder,
24
+ PromptSnapshot,
25
+ PromptSource,
26
+ ReviewedBuild,
27
+ )
28
+ from aiscript.runner import run_script
29
+
30
+
31
+ def _snapshot(path: Path) -> PromptSnapshot:
32
+ try:
33
+ source = path.resolve(strict=True)
34
+ if not source.is_file():
35
+ raise AiscriptError(f"Not a regular prompt file: {path}")
36
+ with source.open(encoding="utf-8", newline="") as handle:
37
+ prompt = handle.read()
38
+ except (OSError, UnicodeError, RuntimeError) as exc:
39
+ raise AiscriptError(f"Cannot read prompt file {path}: {exc}") from exc
40
+ if prompt.startswith("#!"):
41
+ prompt = prompt.partition("\n")[2]
42
+ if not prompt.strip():
43
+ raise AiscriptError(f"Prompt file is empty: {path}")
44
+ digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
45
+ return PromptSnapshot(source, prompt, digest)
46
+
47
+
48
+ @contextlib.contextmanager
49
+ def _lock(path: Path) -> Generator[None, None, None]:
50
+ with path.open("a+b") as handle:
51
+ if os.name == "nt":
52
+ import msvcrt
53
+
54
+ handle.seek(0, os.SEEK_END)
55
+ if handle.tell() == 0:
56
+ handle.write(b"0")
57
+ handle.flush()
58
+ handle.seek(0)
59
+ # LK_LOCK has a finite retry limit, so use blocking OS-level locking below.
60
+ import ctypes
61
+ from ctypes import wintypes
62
+
63
+ class Overlapped(ctypes.Structure):
64
+ _fields_ = [
65
+ ("Internal", ctypes.c_size_t),
66
+ ("InternalHigh", ctypes.c_size_t),
67
+ ("Offset", wintypes.DWORD),
68
+ ("OffsetHigh", wintypes.DWORD),
69
+ ("hEvent", wintypes.HANDLE),
70
+ ]
71
+
72
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
73
+ overlap = Overlapped()
74
+ raw = msvcrt.get_osfhandle(handle.fileno()) # type: ignore[attr-defined]
75
+ if not kernel.LockFileEx(wintypes.HANDLE(raw), 2, 0, 1, 0, ctypes.byref(overlap)):
76
+ raise OSError("Could not acquire prompt file cache lock")
77
+ try:
78
+ yield
79
+ finally:
80
+ if not kernel.UnlockFileEx(wintypes.HANDLE(raw), 0, 1, 0, ctypes.byref(overlap)):
81
+ raise OSError("Could not release prompt file cache lock")
82
+ else:
83
+ import fcntl
84
+
85
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
86
+ try:
87
+ yield
88
+ finally:
89
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
90
+
91
+
92
+ def _build(snapshot: PromptSnapshot, destination: Path, settings: Settings) -> Path:
93
+ name = "prompt-file"
94
+ script = builder.generate_reviewed(name, snapshot.prompt, settings)
95
+ if script.dependencies:
96
+ target = destination / "main.py"
97
+ target.write_text(script.code, encoding="utf-8", newline="\n")
98
+ deps = pysetup._normalise(script.dependencies)
99
+ python = env.find_python()
100
+ uv = env.uv_path()
101
+ print(f"Installing dependencies for {name!r}: {', '.join(deps)}...", file=sys.stderr)
102
+ if uv:
103
+ pysetup._install_uv(uv, name, destination, deps, python)
104
+ else:
105
+ pysetup._install_pip(destination, deps, python)
106
+ else:
107
+ ext = {"python": "py", "powershell": "ps1", "bash": "sh"}[script.language]
108
+ target = destination / f"script.{ext}"
109
+ target.write_text(script.code, encoding="utf-8", newline="\n")
110
+ if not paths.is_windows():
111
+ target.chmod(0o755)
112
+ return target
113
+
114
+
115
+ def _lookup(manifest: Path, snapshot: PromptSnapshot, root: Path) -> Path | None:
116
+ if not manifest.exists():
117
+ return None
118
+ try:
119
+ data = json.loads(manifest.read_text(encoding="utf-8"))
120
+ if not isinstance(data, dict):
121
+ raise ValueError("manifest is not an object")
122
+ relative = data.get(snapshot.digest)
123
+ if relative is None:
124
+ return None
125
+ if not isinstance(relative, str):
126
+ raise ValueError("invalid entrypoint")
127
+ entry = (root / relative).resolve()
128
+ if not entry.is_relative_to(root.resolve()) or not entry.is_file():
129
+ raise ValueError("cached entrypoint is missing or outside cache")
130
+ return entry
131
+ except (OSError, ValueError) as exc:
132
+ raise AiscriptError(f"Cannot read prompt cache: {exc}") from exc
133
+
134
+
135
+ def _publish(manifest: Path, snapshot: PromptSnapshot, root: Path, entry: Path) -> None:
136
+ data: dict[str, str] = {}
137
+ if manifest.exists():
138
+ value = json.loads(manifest.read_text(encoding="utf-8"))
139
+ if not isinstance(value, dict):
140
+ raise ValueError("invalid cache manifest")
141
+ data = value
142
+ data[snapshot.digest] = str(entry.relative_to(root))
143
+ fd, temporary = tempfile.mkstemp(dir=root, prefix=".manifest-")
144
+ try:
145
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
146
+ json.dump(data, handle)
147
+ os.replace(temporary, manifest)
148
+ except BaseException:
149
+ with contextlib.suppress(OSError):
150
+ os.unlink(temporary)
151
+ raise
152
+
153
+
154
+ def execute(path: Path, args: list[str], settings: Callable[[], Settings]) -> int:
155
+ """Run an explicit prompt file through the approved execution ports."""
156
+ use_case = FileExecution(
157
+ LocalPromptSource(),
158
+ LocalFileBuildCache(),
159
+ LocalPromptFileBuilder(settings),
160
+ LocalProgramRunner(),
161
+ )
162
+ return use_case.execute(path, args)
163
+
164
+
165
+ class LocalPromptSource:
166
+ def read(self, path: Path) -> PromptSnapshot:
167
+ return _snapshot(path)
168
+
169
+
170
+ class LocalBuildLease:
171
+ def __init__(self, root: Path) -> None:
172
+ self.root = root
173
+
174
+ def find(self, snapshot: PromptSnapshot) -> ReviewedBuild | None:
175
+ entry = _lookup(self.root / "manifest.json", snapshot, self.root)
176
+ return ReviewedBuild(snapshot.digest, entry) if entry is not None else None
177
+
178
+ def allocate(self) -> BuildDestination:
179
+ return BuildDestination(Path(tempfile.mkdtemp(prefix="version-", dir=self.root)))
180
+
181
+ def publish(
182
+ self, snapshot: PromptSnapshot, destination: BuildDestination, entrypoint: Path
183
+ ) -> ReviewedBuild:
184
+ if not entrypoint.resolve().is_relative_to(destination.version_dir.resolve()):
185
+ raise AiscriptError("Built entrypoint is outside its version directory")
186
+ _publish(self.root / "manifest.json", snapshot, self.root, entrypoint)
187
+ return ReviewedBuild(snapshot.digest, entrypoint)
188
+
189
+ def discard(self, destination: BuildDestination) -> None:
190
+ shutil.rmtree(destination.version_dir, ignore_errors=True)
191
+
192
+
193
+ class LocalFileBuildCache:
194
+ @contextlib.contextmanager
195
+ def acquire(self, source: Path) -> Generator[BuildLease, None, None]:
196
+ identity = hashlib.sha256(os.fsencode(str(source))).hexdigest()
197
+ root = paths.aiscript_home() / "file-cache" / identity
198
+ root.mkdir(parents=True, exist_ok=True)
199
+ with _lock(root / ".lock"):
200
+ yield LocalBuildLease(root)
201
+
202
+
203
+ class LocalPromptFileBuilder:
204
+ def __init__(self, settings: Callable[[], Settings]) -> None:
205
+ self.settings = settings
206
+
207
+ def build(self, snapshot: PromptSnapshot, destination: BuildDestination) -> Path:
208
+ return _build(snapshot, destination.version_dir, self.settings())
209
+
210
+
211
+ class LocalProgramRunner:
212
+ def run(self, build: ReviewedBuild, args: Sequence[str]) -> int:
213
+ return run_script(build.entrypoint, list(args))
214
+
215
+
216
+ class FileExecution:
217
+ def __init__(
218
+ self,
219
+ source: PromptSource,
220
+ cache: FileBuildCache,
221
+ builder_port: PromptFileBuilder,
222
+ runner: ProgramRunner,
223
+ ) -> None:
224
+ self.source = source
225
+ self.cache = cache
226
+ self.builder = builder_port
227
+ self.runner = runner
228
+
229
+ def execute(self, path: Path, args: Sequence[str]) -> int:
230
+ snapshot = self.source.read(path)
231
+ try:
232
+ with self.cache.acquire(snapshot.source) as lease:
233
+ build = lease.find(snapshot)
234
+ if build is None:
235
+ destination = lease.allocate()
236
+ try:
237
+ entry = self.builder.build(snapshot, destination)
238
+ build = lease.publish(snapshot, destination, entry)
239
+ except BaseException:
240
+ lease.discard(destination)
241
+ raise
242
+ return self.runner.run(build, args)
243
+ except (OSError, ValueError) as exc:
244
+ raise AiscriptError(f"Prompt file cache error: {exc}") from exc
File without changes
File without changes
File without changes
File without changes