Skip to content

Commit 21577ef

Browse files
ctruedenclaude
andcommitted
Support environments declared inline, a la PEP 723
A script can now declare its environment in a "# /// script" metadata block, including the [tool.pixi.*] extensions of "pixi run --script", so that it stands alone, and can use conda packages such as CUDA. Appose builds environments in directories of its own rather than in pixi's script cache, so the metadata is translated into an equivalent pyproject.toml, mirroring pixi's own translation and its list of supported [tool.pixi.*] keys. The StarDist template now declares its environment inline, since templates open as unsaved scripts, which cannot refer to a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1178547 commit 21577ef

11 files changed

Lines changed: 683 additions & 37 deletions

File tree

‎GAPS.md‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,44 @@ worker, every such output leaked one block.
154154
nothing checks directly that blocks are freed. A shared memory pool in
155155
Appose core (planned) would make ownership explicit.
156156

157+
## Inline environments
158+
159+
**Now:** `InlineMetadata` translates a PEP 723 block into a `pyproject.toml`
160+
for Appose's pixi builder: `requires-python` and `dependencies` move into
161+
`[project]`, `[tool.*]` tables are kept, and `[tool.pixi.workspace]` gains
162+
`channels = ["conda-forge"]` and the running platform if it lacks them, since
163+
a pixi workspace requires both. This mirrors pixi's own `inline_pyproject`,
164+
including its list of allowed `[tool.pixi.*]` keys, so a script accepted here
165+
also runs with `pixi run --script`.
166+
167+
**Gaps:**
168+
169+
- **Relative paths.** pixi resolves relative paths in script metadata (e.g.
170+
an editable `path` dependency) from the script's directory. Here they
171+
resolve from the environment directory, so they break. Rewriting them to
172+
absolute paths is possible, but meaningless for scripts inside a JAR.
173+
- **Lock files.** A `<script>.pixi.lock` sidecar, as written by
174+
`pixi lock --script`, is ignored; each environment is locked afresh.
175+
- **Unsaved scripts.** An inline environment is named after its script's
176+
path. Unsaved scripts (e.g. new from a template) have none, so they are
177+
named by a hash of the metadata instead. Editing the metadata of an unsaved
178+
script therefore creates a new environment and worker, and the old worker
179+
keeps running until the application exits.
180+
- **The generated file is not readable by Appose's scheme detection.** Its
181+
TOML uses dotted keys (`project.name = ...`), while Appose's line-based
182+
detection looks for `[project]`. The engine passes the scheme explicitly.
183+
In Appose core, inline metadata should be a scheme of its own, so any
184+
Appose user can build from a script.
185+
186+
## Environment files inside JARs
187+
188+
**Now:** `#@script(env="...")` is resolved against the script's file path.
189+
Scripts inside a JAR have no such file, so they cannot use environment files.
190+
Resolving the reference as a URL relative to the script's own URL (e.g.
191+
`jar:file:...!/scripts/env.toml`) would fix that, while keeping a script and
192+
its environment from the same origin, but it has not been done. Inline
193+
environments avoid the problem.
194+
157195
## Not yet tried
158196

159197
- **Real Fiji GUI.** All tests are headless. The build and run tasks have not
@@ -168,7 +206,7 @@ worker, every such output leaked one block.
168206

169207
| Class | Destination |
170208
| --- | --- |
171-
| `BuildListener`, `LazyEnvironment`, `ResidentWorker` | Appose core |
209+
| `BuildListener`, `InlineMetadata`, `LazyEnvironment`, `ResidentWorker` | Appose core |
172210
| `ResidentWorkerService`, `SciJavaTasks` | `scijava/scijava-appose` |
173211
| `NDArrayToImgConverter`, `RAIToNDArrayConverter`, ImageJ2 dependencies | `fiji/fiji-appose` |
174212

‎README.md‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ from the Script Editor or menus of ImageJ2/Fiji like any other script.
1515

1616
```python
1717
#!appose-python
18-
#@script(env="myenv.toml")
18+
# /// script
19+
# requires-python = ">=3.12"
20+
# dependencies = ["appose", "scipy"]
21+
# ///
1922

2023
#@ Img image
2124
#@ double sigma
@@ -29,24 +32,61 @@ blurred = gaussian_filter(image, sigma)
2932

3033
* The `#!appose-python` line selects this script language, rather than
3134
another language handling `.py` files, such as Jython.
32-
* The `env` attribute points to an environment configuration file, resolved
33-
relative to the script's location. Any format Appose supports works:
34-
`pixi.toml`, `environment.yml`, `requirements.txt` or `pyproject.toml`.
35-
An optional `scheme` attribute (e.g. `scheme="pixi.toml"`) sets the format
36-
explicitly, which is useful when the file name does not reveal it.
35+
* The `# /// script` block declares the script's Python environment; see
36+
below.
3737
* The environment must include the `appose` Python package, plus `numpy` if
3838
the script uses images.
3939

4040
See the `StarDist_cellcast.py` template for a complete example.
4141

4242
## Environments
4343

44+
A script declares its environment in one of two ways.
45+
46+
**Inline**, with a [PEP 723](https://packaging.python.org/en/latest/specifications/inline-script-metadata/)
47+
metadata block, as above. `requires-python` selects the Python version, and
48+
`dependencies` lists packages from PyPI. The `[tool.pixi.*]` extensions of
49+
[`pixi run --script`](https://pixi.sh/latest/python/scripts/) are supported
50+
too, so conda packages are available, e.g. to get CUDA libraries from
51+
conda-forge:
52+
53+
```python
54+
# /// script
55+
# requires-python = ">=3.12"
56+
# dependencies = ["appose"]
57+
#
58+
# [tool.pixi.dependencies]
59+
# pytorch-gpu = "*"
60+
#
61+
# [tool.pixi.system-requirements]
62+
# cuda = "12"
63+
# ///
64+
```
65+
66+
If `[tool.pixi.workspace]` does not say otherwise, packages come from
67+
conda-forge, for the platform the script runs on. Inline environments are the
68+
way to go for scripts that must stand alone, such as templates.
69+
70+
**In a file**, named by the `env` attribute of the `#@script` directive:
71+
72+
```python
73+
#@script(env="myenv.toml")
74+
```
75+
76+
The file is resolved relative to the script's location. Any format Appose
77+
supports works: `pixi.toml`, `environment.yml`, `requirements.txt` or
78+
`pyproject.toml`. An optional `scheme` attribute (e.g. `scheme="pixi.toml"`)
79+
sets the format explicitly, which is useful when the file name does not reveal
80+
it. Scripts that share a file share an environment.
81+
82+
A script cannot do both.
83+
4484
The environment is built the first time a script needs it, which may take a
4585
while. Afterward, it is reused, both within the running application and across
4686
restarts. Environments are stored in the Appose environments directory
47-
(`~/.local/share/appose` by default), named after the configuration file.
48-
Scripts that share a configuration file share an environment. Editing the file
49-
causes the environment to be updated on the next run.
87+
(`~/.local/share/appose` by default), named after the file declaring them: the
88+
environment file, or the script itself for inline environments. Editing the
89+
declaration causes the environment to be updated on the next run.
5090

5191
Builds appear in the application's task list, with their progress, so a
5292
long first build does not look like a hang.

‎pom.xml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@
108108
</dependency>
109109

110110
<!-- Third-party dependencies -->
111+
<dependency>
112+
<groupId>com.fasterxml.jackson.core</groupId>
113+
<artifactId>jackson-databind</artifactId>
114+
</dependency>
115+
<dependency>
116+
<groupId>com.fasterxml.jackson.dataformat</groupId>
117+
<artifactId>jackson-dataformat-toml</artifactId>
118+
<version>${jackson.version}</version>
119+
</dependency>
111120
<dependency>
112121
<groupId>com.fifesoft</groupId>
113122
<artifactId>rsyntaxtextarea</artifactId>

‎src/main/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngine.java‎

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
import org.scijava.log.Logger;
7171
import org.scijava.module.ModuleItem;
7272
import org.scijava.plugin.Parameter;
73+
import org.scijava.plugins.scripting.appose.python._internal.InlineMetadata;
7374
import org.scijava.plugins.scripting.appose.python._internal.ResidentWorker;
7475
import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
7576
import org.scijava.plugins.scripting.appose.python._internal.SciJavaTasks;
@@ -156,7 +157,7 @@ public Object eval(final String script) throws ScriptException {
156157
((ScriptModule) moduleObj).getInfo() : null;
157158

158159
// Retrieve the resident worker for the script's environment.
159-
final ResidentWorker worker = worker(info);
160+
final ResidentWorker worker = worker(info, script);
160161

161162
// Collect declared inputs, converting array-like values to NDArray.
162163
final Map<String, Object> taskInputs = new HashMap<>();
@@ -439,18 +440,33 @@ static boolean ownsNDArray(final Object value, final NDArray nd) {
439440
}
440441

441442
/**
442-
* Gets the resident worker for the Appose environment described by the
443-
* {@code env} and {@code scheme} attributes of the script's
444-
* {@code #@script} directive. The environment itself is built lazily, by
445-
* the worker's first task.
443+
* Gets the resident worker for the script's Appose environment, which the
444+
* script declares either inline, via a PEP 723 {@code # /// script}
445+
* metadata block, or in a file named by the {@code env} (and optionally
446+
* {@code scheme}) attributes of its {@code #@script} directive. The
447+
* environment itself is built lazily, by the worker's first task.
446448
*/
447-
private ResidentWorker worker(final ScriptInfo info) throws ScriptException {
448-
final String envRef = info == null ? null : info.get("env");
449-
if (envRef == null) {
450-
throw new ScriptException(
451-
"No Appose environment configured. " +
452-
"Add #@script(env=\"myenv.toml\") to declare one.");
449+
private ResidentWorker worker(final ScriptInfo info, final String script)
450+
throws ScriptException
451+
{
452+
if (info == null) throw noEnvironment();
453+
final String envRef = info.get("env");
454+
final String metadata;
455+
try {
456+
metadata = InlineMetadata.extract(script);
457+
}
458+
catch (final IllegalArgumentException e) {
459+
throw scriptException(e.getMessage(), e);
460+
}
461+
if (metadata != null) {
462+
if (envRef != null) {
463+
throw new ScriptException("The script declares its environment " +
464+
"twice: inline, and via #@script(env=\"" + envRef + "\"). " +
465+
"Remove one of them.");
466+
}
467+
return inlineWorker(metadata, info.getPath());
453468
}
469+
if (envRef == null) throw noEnvironment();
454470

455471
final File envFile = resolveEnvFile(envRef, info.getPath());
456472
if (!envFile.isFile()) {
@@ -466,9 +482,36 @@ private ResidentWorker worker(final ScriptInfo info) throws ScriptException {
466482
throw scriptException("Cannot read Appose environment file: " +
467483
envFile.getAbsolutePath(), e);
468484
}
469-
final String envName = envName(envFile);
470-
final String scheme = info.get("scheme");
485+
return worker(envName(envFile), content, info.get("scheme"));
486+
}
471487

488+
/** Gets the resident worker for an environment declared inline. */
489+
private ResidentWorker inlineWorker(final String metadata,
490+
final String scriptPath) throws ScriptException
491+
{
492+
// Note: Name the environment after the script, as for environment
493+
// files, so that editing the metadata updates the environment and
494+
// replaces its worker, rather than leaving the old one running.
495+
// Unsaved scripts, e.g. new from a template, have no path to go by.
496+
final String envName = scriptPath == null ? "script-" + String.format(
497+
"%08x", metadata.hashCode()) : envName(new File(scriptPath));
498+
final String content;
499+
try {
500+
content = InlineMetadata.toPyProject(metadata, envName);
501+
}
502+
catch (final IllegalArgumentException e) {
503+
throw scriptException(e.getMessage(), e);
504+
}
505+
return worker(envName, content, InlineMetadata.SCHEME);
506+
}
507+
508+
/**
509+
* Gets the resident worker for the named environment, built from the given
510+
* configuration.
511+
*/
512+
private ResidentWorker worker(final String envName, final String content,
513+
final String scheme)
514+
{
472515
return workerService.worker(envName, scheme + "\n" + content, () -> {
473516
final Builder<?> builder = Appose.content(content);
474517
return scheme == null ? builder : builder.scheme(scheme);
@@ -505,6 +548,12 @@ static String envName(final File envFile) {
505548
String.format("%08x", path.hashCode());
506549
}
507550

551+
private static ScriptException noEnvironment() {
552+
return new ScriptException("No Appose environment configured. " +
553+
"Declare one inline with a '# /// script' metadata block (PEP 723), " +
554+
"or in a file via #@script(env=\"myenv.toml\").");
555+
}
556+
508557
private static ScriptException scriptException(final String message,
509558
final Throwable cause)
510559
{

0 commit comments

Comments
 (0)