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.
- {aiscript-1.2.0 → aiscript-1.3.0}/PKG-INFO +57 -1
- {aiscript-1.2.0 → aiscript-1.3.0}/README.md +56 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/pyproject.toml +1 -1
- {aiscript-1.2.0 → aiscript-1.3.0}/pyproject.toml.orig +1 -1
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/__init__.py +1 -1
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/builder.py +9 -4
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/cli.py +5 -1
- aiscript-1.3.0/src/aiscript/file_contracts.py +87 -0
- aiscript-1.3.0/src/aiscript/prompt_file.py +244 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/LICENSE +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/__main__.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/config.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/decisions.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/env.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/errors.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/init_cmd.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/listing.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/llm.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/paths.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/pysetup.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/reviewer.py +0 -0
- {aiscript-1.2.0 → aiscript-1.3.0}/src/aiscript/runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiscript
|
|
3
|
-
Version: 1.
|
|
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
|
|
@@ -38,16 +38,21 @@ def _read_intent(name: str) -> str:
|
|
|
38
38
|
return intent
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def
|
|
42
|
-
"""Generate
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|