asyagent 0.1.0__py3-none-any.whl

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.
asyagent/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """asyagent — an HTTP service that compiles Asymptote (*.asy) sources and
2
+ returns the rendered output either inline (binary/base64) or as an
3
+ object-storage URL.
4
+
5
+ Pure Python 3.10+ standard library. No third-party dependencies.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
asyagent/__main__.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .config import Settings
6
+ from .server import run
7
+
8
+
9
+ def main() -> int:
10
+ settings = Settings.from_env()
11
+ try:
12
+ run(settings)
13
+ except OSError as e:
14
+ print(f"asyagent: failed to start server: {e}", file=sys.stderr)
15
+ return 1
16
+ return 0
17
+
18
+
19
+ if __name__ == "__main__":
20
+ raise SystemExit(main())
asyagent/compiler.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import uuid
8
+ import zipfile
9
+ from dataclasses import dataclass
10
+
11
+ from .errors import CompileError, CompileTimeout, RasterError, UnsupportedFormat
12
+
13
+ NATIVE_FORMATS = ("pdf", "svg", "eps")
14
+ RASTER_FORMATS = ("png", "jpg", "jpeg")
15
+ SUPPORTED_FORMATS = NATIVE_FORMATS + RASTER_FORMATS
16
+
17
+ MIME_BY_EXT = {
18
+ "pdf": "application/pdf",
19
+ "svg": "image/svg+xml",
20
+ "eps": "application/postscript",
21
+ "png": "image/png",
22
+ "jpg": "image/jpeg",
23
+ "jpeg": "image/jpeg",
24
+ "zip": "application/zip",
25
+ }
26
+
27
+ GS_DEVICE = {
28
+ "png": "pngalpha",
29
+ "jpg": "jpeg",
30
+ "jpeg": "jpeg",
31
+ }
32
+
33
+ OUTPUT_EXTS = set(NATIVE_FORMATS)
34
+
35
+
36
+ @dataclass
37
+ class CompiledFile:
38
+ name: str
39
+ ext: str
40
+ mime: str
41
+ data: bytes
42
+
43
+ @property
44
+ def size(self) -> int:
45
+ return len(self.data)
46
+
47
+
48
+ def validate_format(fmt: str) -> str:
49
+ fmt = fmt.lower()
50
+ if fmt not in SUPPORTED_FORMATS:
51
+ raise UnsupportedFormat(
52
+ f"unsupported format: {fmt!r}",
53
+ detail=f"supported: {', '.join(SUPPORTED_FORMATS)}",
54
+ )
55
+ return fmt
56
+
57
+
58
+ def _snapshot(workdir: str) -> set[str]:
59
+ names = set()
60
+ for entry in os.listdir(workdir):
61
+ if os.path.isfile(os.path.join(workdir, entry)):
62
+ names.add(entry)
63
+ return names
64
+
65
+
66
+ def _run(cmd, *, cwd, timeout, label) -> subprocess.CompletedProcess:
67
+ env = dict(os.environ)
68
+ env.setdefault("HOME", os.path.expanduser("~"))
69
+ try:
70
+ return subprocess.run(
71
+ cmd,
72
+ cwd=cwd,
73
+ env=env,
74
+ capture_output=True,
75
+ timeout=timeout,
76
+ )
77
+ except subprocess.TimeoutExpired as e:
78
+ out = (e.stdout or b"") + b"\n" + (e.stderr or b"")
79
+ raise CompileTimeout(
80
+ f"{label} timed out after {timeout}s",
81
+ detail=out.decode("utf-8", "replace")[-2000:],
82
+ ) from e
83
+
84
+
85
+ def _rasterize(pdf_path, *, workdir, stem, ext, device, dpi, gs_bin, timeout) -> list[str]:
86
+ pattern = os.path.join(workdir, f"{stem}_{ext}_%d.{ext}")
87
+ cmd = [
88
+ gs_bin,
89
+ "-q",
90
+ "-dNOPAUSE",
91
+ "-dBATCH",
92
+ "-dSAFER",
93
+ f"-sDEVICE={device}",
94
+ f"-r{dpi}",
95
+ "-dTextAlphaBits=4",
96
+ "-dGraphicsAlphaBits=4",
97
+ f"-sOutputFile={pattern}",
98
+ pdf_path,
99
+ ]
100
+ try:
101
+ result = subprocess.run(
102
+ cmd, cwd=workdir, capture_output=True, timeout=timeout
103
+ )
104
+ except subprocess.TimeoutExpired as e:
105
+ raise RasterError(
106
+ f"ghostscript timed out after {timeout}s",
107
+ detail=(e.stderr or b"").decode("utf-8", "replace")[-2000:],
108
+ ) from e
109
+ if result.returncode != 0:
110
+ raise RasterError(
111
+ "ghostscript rasterization failed",
112
+ detail=result.stderr.decode("utf-8", "replace")[-2000:],
113
+ )
114
+
115
+ produced = sorted(
116
+ f for f in os.listdir(workdir) if f.startswith(f"{stem}_{ext}_") and f.endswith(f".{ext}")
117
+ )
118
+ if not produced:
119
+ raise RasterError("ghostscript produced no output", detail=pdf_path)
120
+ return produced
121
+
122
+
123
+ def compile_source(
124
+ source: str,
125
+ *,
126
+ fmt: str,
127
+ dpi: int,
128
+ timeout: int,
129
+ asy_bin: str,
130
+ gs_bin: str,
131
+ tmp_dir: str | None,
132
+ ) -> list[CompiledFile]:
133
+ fmt = validate_format(fmt)
134
+ native = fmt if fmt in NATIVE_FORMATS else "pdf"
135
+
136
+ workdir = tempfile.mkdtemp(prefix="asyagent_", dir=tmp_dir)
137
+ try:
138
+ input_path = os.path.join(workdir, "input.asy")
139
+ with open(input_path, "w", encoding="utf-8") as f:
140
+ f.write(source)
141
+ if not source.endswith("\n"):
142
+ f.write("\n")
143
+
144
+ before = _snapshot(workdir)
145
+ cmd = [asy_bin, "-f", native, "-o", "out", "input.asy"]
146
+ result = _run(cmd, cwd=workdir, timeout=timeout, label="asy")
147
+
148
+ if result.returncode != 0:
149
+ stderr = result.stderr.decode("utf-8", "replace")
150
+ stdout = result.stdout.decode("utf-8", "replace")
151
+ detail = (stderr + "\n" + stdout).strip()[-4000:]
152
+ raise CompileError("asymptote compilation failed", detail=detail)
153
+
154
+ after = _snapshot(workdir)
155
+ new_outputs = sorted(
156
+ n for n in (after - before) if n.rsplit(".", 1)[-1].lower() in OUTPUT_EXTS
157
+ )
158
+
159
+ if not new_outputs:
160
+ listing = sorted(after - before)
161
+ raise CompileError(
162
+ "compilation produced no output",
163
+ detail=f"files produced: {listing}",
164
+ )
165
+
166
+ if fmt in NATIVE_FORMATS:
167
+ chosen = new_outputs
168
+ else:
169
+ device = GS_DEVICE[fmt]
170
+ chosen = []
171
+ for pdf_name in new_outputs:
172
+ stem = pdf_name.rsplit(".", 1)[0]
173
+ raster_names = _rasterize(
174
+ os.path.join(workdir, pdf_name),
175
+ workdir=workdir,
176
+ stem=stem,
177
+ ext=fmt,
178
+ device=device,
179
+ dpi=dpi,
180
+ gs_bin=gs_bin,
181
+ timeout=timeout,
182
+ )
183
+ chosen.extend(raster_names)
184
+
185
+ chosen = sorted(set(chosen))
186
+ files: list[CompiledFile] = []
187
+ for name in chosen:
188
+ ext = name.rsplit(".", 1)[-1].lower()
189
+ if ext == "jpeg":
190
+ ext = "jpg"
191
+ with open(os.path.join(workdir, name), "rb") as f:
192
+ data = f.read()
193
+ files.append(
194
+ CompiledFile(name=name, ext=ext, mime=MIME_BY_EXT.get(ext, "application/octet-stream"), data=data)
195
+ )
196
+ return files
197
+ finally:
198
+ shutil.rmtree(workdir, ignore_errors=True)
199
+
200
+
201
+ def select_or_bundle(files: list[CompiledFile]) -> CompiledFile:
202
+ if not files:
203
+ raise CompileError("no output to return")
204
+ if len(files) == 1:
205
+ return files[0]
206
+
207
+ import io
208
+
209
+ buf = io.BytesIO()
210
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
211
+ for f in files:
212
+ zf.writestr(f.name, f.data)
213
+ return CompiledFile(
214
+ name="asyagent-output.zip",
215
+ ext="zip",
216
+ mime=MIME_BY_EXT["zip"],
217
+ data=buf.getvalue(),
218
+ )
asyagent/config.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def _env(name: str, default: str = "") -> str:
8
+ return os.environ.get(name, default)
9
+
10
+
11
+ def _env_or_none(name: str) -> str | None:
12
+ v = os.environ.get(name)
13
+ return v if v not in (None, "") else None
14
+
15
+
16
+ def _int(name: str, default: int) -> int:
17
+ raw = os.environ.get(name)
18
+ if raw is None or raw == "":
19
+ return default
20
+ try:
21
+ return int(raw)
22
+ except ValueError:
23
+ return default
24
+
25
+
26
+ def _bool(name: str, default: bool) -> bool:
27
+ raw = os.environ.get(name)
28
+ if raw is None or raw == "":
29
+ return default
30
+ return raw.strip().lower() in ("1", "true", "yes", "on")
31
+
32
+
33
+ @dataclass
34
+ class Settings:
35
+ host: str
36
+ port: int
37
+ max_workers: int
38
+ compile_timeout: int
39
+ max_timeout: int
40
+ fetch_timeout: int
41
+ max_source_bytes: int
42
+ max_fetch_bytes: int
43
+ asy_bin: str
44
+ gs_bin: str
45
+ default_format: str
46
+ default_mode: str
47
+ default_encoding: str
48
+ default_dpi: int
49
+ tmp_dir: str | None
50
+
51
+ storage_backend: str
52
+ local_dir: str
53
+ local_base_url: str | None
54
+
55
+ s3_endpoint: str | None
56
+ s3_region: str
57
+ s3_bucket: str | None
58
+ s3_prefix: str
59
+ s3_access_key: str | None
60
+ s3_secret_key: str | None
61
+ s3_security_token: str | None
62
+ s3_url_style: str
63
+ s3_public_base_url: str | None
64
+ s3_presign: bool
65
+ s3_presign_expires: int
66
+ s3_use_tls: bool
67
+
68
+ @classmethod
69
+ def from_env(cls) -> "Settings":
70
+ host = _env("ASYAGENT_HOST", "0.0.0.0")
71
+ port = _int("ASYAGENT_PORT", 8787)
72
+ default_format = _env("ASYAGENT_DEFAULT_FORMAT", "pdf").lower()
73
+ default_mode = _env("ASYAGENT_DEFAULT_MODE", "inline").lower()
74
+ default_encoding = _env("ASYAGENT_DEFAULT_ENCODING", "binary").lower()
75
+
76
+ backend = _env("ASYAGENT_STORAGE", "local").lower()
77
+ s3_endpoint = _env_or_none("S3_ENDPOINT") or _env_or_none("AWS_ENDPOINT_URL")
78
+ s3_region = _env("AWS_REGION", _env("AWS_DEFAULT_REGION", "us-east-1"))
79
+ s3_url_style = _env("S3_URL_STYLE", _env("S3_PATH_STYLE", "path")).lower()
80
+ if s3_url_style not in ("path", "virtual"):
81
+ s3_url_style = "path"
82
+
83
+ local_base_url = _env_or_none("ASYAGENT_LOCAL_BASE_URL")
84
+ if local_base_url is None:
85
+ local_base_url = f"http://{host if host != '0.0.0.0' else '127.0.0.1'}:{port}"
86
+
87
+ return cls(
88
+ host=host,
89
+ port=port,
90
+ max_workers=_int("ASYAGENT_MAX_WORKERS", 16),
91
+ compile_timeout=_int("ASYAGENT_COMPILE_TIMEOUT", 60),
92
+ max_timeout=_int("ASYAGENT_MAX_TIMEOUT", 300),
93
+ fetch_timeout=_int("ASYAGENT_FETCH_TIMEOUT", 20),
94
+ max_source_bytes=_int("ASYAGENT_MAX_SOURCE_BYTES", 1_048_576),
95
+ max_fetch_bytes=_int("ASYAGENT_MAX_FETCH_BYTES", 5_242_880),
96
+ asy_bin=_env("ASYAGENT_ASY_BIN", "asy"),
97
+ gs_bin=_env("ASYAGENT_GS_BIN", "gs"),
98
+ default_format=default_format,
99
+ default_mode=default_mode,
100
+ default_encoding=default_encoding,
101
+ default_dpi=_int("ASYAGENT_DEFAULT_DPI", 150),
102
+ tmp_dir=_env_or_none("ASYAGENT_TMP_DIR"),
103
+ storage_backend=backend,
104
+ local_dir=_env("ASYAGENT_LOCAL_DIR", "./storage"),
105
+ local_base_url=local_base_url,
106
+ s3_endpoint=s3_endpoint,
107
+ s3_region=s3_region,
108
+ s3_bucket=_env_or_none("S3_BUCKET"),
109
+ s3_prefix=_env("S3_PREFIX", "asyagent/"),
110
+ s3_access_key=_env_or_none("AWS_ACCESS_KEY_ID"),
111
+ s3_secret_key=_env_or_none("AWS_SECRET_ACCESS_KEY"),
112
+ s3_security_token=_env_or_none("AWS_SESSION_TOKEN")
113
+ or _env_or_none("AWS_SECURITY_TOKEN"),
114
+ s3_url_style=s3_url_style,
115
+ s3_public_base_url=_env_or_none("S3_PUBLIC_BASE_URL"),
116
+ s3_presign=_bool("S3_PRESIGN", False),
117
+ s3_presign_expires=_int("S3_PRESIGN_EXPIRES", 3600),
118
+ s3_use_tls=_bool("S3_USE_TLS", True),
119
+ )
120
+
121
+ def with_overrides(
122
+ self,
123
+ *,
124
+ storage_prefix: str | None = None,
125
+ storage_bucket: str | None = None,
126
+ ) -> "Settings":
127
+ return Settings(
128
+ host=self.host,
129
+ port=self.port,
130
+ max_workers=self.max_workers,
131
+ compile_timeout=self.compile_timeout,
132
+ max_timeout=self.max_timeout,
133
+ fetch_timeout=self.fetch_timeout,
134
+ max_source_bytes=self.max_source_bytes,
135
+ max_fetch_bytes=self.max_fetch_bytes,
136
+ asy_bin=self.asy_bin,
137
+ gs_bin=self.gs_bin,
138
+ default_format=self.default_format,
139
+ default_mode=self.default_mode,
140
+ default_encoding=self.default_encoding,
141
+ default_dpi=self.default_dpi,
142
+ tmp_dir=self.tmp_dir,
143
+ storage_backend=self.storage_backend,
144
+ local_dir=self.local_dir,
145
+ local_base_url=self.local_base_url,
146
+ s3_endpoint=self.s3_endpoint,
147
+ s3_region=self.s3_region,
148
+ s3_bucket=storage_bucket or self.s3_bucket,
149
+ s3_prefix=storage_prefix or self.s3_prefix,
150
+ s3_access_key=self.s3_access_key,
151
+ s3_secret_key=self.s3_secret_key,
152
+ s3_security_token=self.s3_security_token,
153
+ s3_url_style=self.s3_url_style,
154
+ s3_public_base_url=self.s3_public_base_url,
155
+ s3_presign=self.s3_presign,
156
+ s3_presign_expires=self.s3_presign_expires,
157
+ s3_use_tls=self.s3_use_tls,
158
+ )
asyagent/errors.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AsyAgentError(Exception):
5
+ status: int = 500
6
+ code: str = "internal_error"
7
+
8
+ def __init__(self, message: str, *, detail: str | None = None) -> None:
9
+ super().__init__(message)
10
+ self.message = message
11
+ self.detail = detail
12
+
13
+ def to_dict(self) -> dict:
14
+ body = {"error": {"code": self.code, "message": self.message}}
15
+ if self.detail:
16
+ body["error"]["detail"] = self.detail
17
+ return body
18
+
19
+
20
+ class BadRequest(AsyAgentError):
21
+ status = 400
22
+ code = "bad_request"
23
+
24
+
25
+ class UnsupportedFormat(BadRequest):
26
+ code = "unsupported_format"
27
+
28
+
29
+ class EmptyInput(BadRequest):
30
+ code = "empty_input"
31
+
32
+
33
+ class InvalidInput(BadRequest):
34
+ code = "invalid_input"
35
+
36
+
37
+ class InputTooLarge(BadRequest):
38
+ code = "input_too_large"
39
+
40
+
41
+ class MissingStorage(BadRequest):
42
+ status = 409
43
+ code = "storage_unavailable"
44
+
45
+
46
+ class FetchError(AsyAgentError):
47
+ status = 502
48
+ code = "fetch_failed"
49
+
50
+
51
+ class CompileError(AsyAgentError):
52
+ status = 422
53
+ code = "compile_failed"
54
+
55
+
56
+ class RasterError(AsyAgentError):
57
+ status = 502
58
+ code = "raster_failed"
59
+
60
+
61
+ class CompileTimeout(AsyAgentError):
62
+ status = 504
63
+ code = "timeout"
64
+
65
+
66
+ class StorageError(AsyAgentError):
67
+ status = 502
68
+ code = "storage_failed"
69
+
70
+
71
+ class ServerBusy(AsyAgentError):
72
+ status = 503
73
+ code = "server_busy"
asyagent/fetcher.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.request
4
+ import urllib.error
5
+
6
+ from .errors import FetchError
7
+
8
+
9
+ def fetch_source(url: str, *, timeout: int, max_bytes: int) -> str:
10
+ scheme = url.split("://", 1)[0].lower() if "://" in url else ""
11
+ if scheme not in ("http", "https"):
12
+ raise FetchError(f"unsupported URL scheme: {scheme!r}")
13
+
14
+ req = urllib.request.Request(url, headers={"User-Agent": "asyagent/0.1"})
15
+ try:
16
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
17
+ content_type = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower()
18
+ declared = resp.headers.get("Content-Length")
19
+ if declared and declared.isdigit() and int(declared) > max_bytes:
20
+ raise FetchError(
21
+ f"remote file too large ({int(declared)} > {max_bytes} bytes)",
22
+ detail=url,
23
+ )
24
+ data = resp.read(max_bytes + 1)
25
+ if len(data) > max_bytes:
26
+ raise FetchError(
27
+ f"remote file too large (> {max_bytes} bytes)", detail=url
28
+ )
29
+ except urllib.error.HTTPError as e:
30
+ raise FetchError(f"remote returned HTTP {e.code}", detail=str(e.reason)) from e
31
+ except urllib.error.URLError as e:
32
+ raise FetchError(f"failed to fetch URL: {e.reason}", detail=url) from e
33
+ except TimeoutError as e:
34
+ raise FetchError(f"fetch timed out after {timeout}s", detail=url) from e
35
+
36
+ try:
37
+ text = data.decode("utf-8")
38
+ except UnicodeDecodeError:
39
+ try:
40
+ text = data.decode("latin-1")
41
+ except Exception as e:
42
+ raise FetchError("remote file is not valid text", detail=url) from e
43
+
44
+ if content_type and content_type not in (
45
+ "",
46
+ "text/plain",
47
+ "text/x-asy",
48
+ "text/y-asy",
49
+ "application/octet-stream",
50
+ "text/markdown",
51
+ ) and not content_type.startswith("text/"):
52
+ raise FetchError(
53
+ f"unexpected remote content-type: {content_type}", detail=url
54
+ )
55
+
56
+ return text
asyagent/py.typed ADDED
File without changes