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 +10 -0
- asyagent/__main__.py +20 -0
- asyagent/compiler.py +218 -0
- asyagent/config.py +158 -0
- asyagent/errors.py +73 -0
- asyagent/fetcher.py +56 -0
- asyagent/py.typed +0 -0
- asyagent/server.py +362 -0
- asyagent/sigv4.py +229 -0
- asyagent/storage.py +252 -0
- asyagent-0.1.0.dist-info/METADATA +262 -0
- asyagent-0.1.0.dist-info/RECORD +15 -0
- asyagent-0.1.0.dist-info/WHEEL +5 -0
- asyagent-0.1.0.dist-info/entry_points.txt +2 -0
- asyagent-0.1.0.dist-info/top_level.txt +1 -0
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
|