zenfmt 0.1.0__py3-none-win_amd64.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.
zenfmt/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """zenfmt: universal document conversion for Python (ZDS 0014).
2
+
3
+ The common operation is one call::
4
+
5
+ import zenfmt
6
+
7
+ conversion = zenfmt.convert("report.docx")
8
+ print(conversion.text)
9
+ for report in conversion.reports:
10
+ print(report.code, report.problem)
11
+
12
+ Importing this package defines Python objects only; the bundled native
13
+ engine loads and verifies itself on the first capability query or
14
+ conversion. There is no global configuration, no environment lookup, and
15
+ no network access.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib.metadata as _metadata
21
+
22
+ from ._convert import Converter, convert, formats
23
+ from ._errors import (
24
+ ConversionError,
25
+ DestinationExistsError,
26
+ Direction,
27
+ InputReadError,
28
+ LimitExceededError,
29
+ NativeLibraryError,
30
+ UnknownFormatError,
31
+ UnsupportedPlatformError,
32
+ ZenfmtError,
33
+ )
34
+ from ._limits import Limits
35
+ from ._models import (
36
+ Context,
37
+ Conversion,
38
+ Format,
39
+ Manifest,
40
+ Report,
41
+ Resource,
42
+ Strictness,
43
+ )
44
+
45
+ try:
46
+ __version__ = _metadata.version("zenfmt")
47
+ except _metadata.PackageNotFoundError: # not installed; source tree use
48
+ __version__ = "0.0.0"
49
+
50
+ __all__ = (
51
+ "Context",
52
+ "Conversion",
53
+ "ConversionError",
54
+ "Converter",
55
+ "DestinationExistsError",
56
+ "Direction",
57
+ "Format",
58
+ "InputReadError",
59
+ "LimitExceededError",
60
+ "Limits",
61
+ "Manifest",
62
+ "NativeLibraryError",
63
+ "Report",
64
+ "Resource",
65
+ "Strictness",
66
+ "UnknownFormatError",
67
+ "UnsupportedPlatformError",
68
+ "ZenfmtError",
69
+ "__version__",
70
+ "convert",
71
+ "formats",
72
+ )
@@ -0,0 +1,102 @@
1
+ """Capability discovery from native metadata (ZDS 0014).
2
+
3
+ The bridge's compile-time capability JSON is the only format table: it
4
+ drives :func:`zenfmt.formats`, alias resolution, artifact naming, and the
5
+ ``Conversion.text`` decision. No hard-coded Python format list exists
6
+ anywhere in this package.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from . import _diagnostics
14
+ from ._loader import Bridge
15
+ from ._models import Format
16
+
17
+ _cache: dict[bytes, Capabilities] = {}
18
+
19
+
20
+ class Capabilities:
21
+ __slots__ = ("_aliases", "default_output_format", "formats", "limits")
22
+
23
+ def __init__(
24
+ self,
25
+ formats: tuple[Format, ...],
26
+ default_output_format: str,
27
+ limits: dict[str, int],
28
+ ) -> None:
29
+ self.formats = formats
30
+ self.default_output_format = default_output_format
31
+ self.limits = limits
32
+ aliases: dict[str, str] = {}
33
+ for entry in formats:
34
+ aliases[entry.name] = entry.name
35
+ for extension in entry.extensions:
36
+ aliases.setdefault(extension, entry.name)
37
+ self._aliases = aliases
38
+
39
+ def resolve(self, alias: str) -> str:
40
+ """Canonical format id for a name or extension-like alias.
41
+
42
+ ASCII case-insensitive; one leading dot is ignored. Unknown
43
+ aliases pass through unchanged so the engine produces the
44
+ canonical unknown-format failure with its suggestions — the
45
+ wrapper never maintains a second table of judgments.
46
+ """
47
+ normalized = alias.strip().lower().removeprefix(".")
48
+ return self._aliases.get(normalized, normalized)
49
+
50
+ def by_name(self, name: str) -> Format | None:
51
+ for entry in self.formats:
52
+ if entry.name == name:
53
+ return entry
54
+ return None
55
+
56
+ def writer_emits_text(self, name: str) -> bool:
57
+ entry = self.by_name(name)
58
+ return bool(entry and entry.text_writer)
59
+
60
+ def primary_extension(self, name: str) -> str | None:
61
+ entry = self.by_name(name)
62
+ return entry.primary_extension if entry else None
63
+
64
+
65
+ def capabilities(bridge: Bridge) -> Capabilities:
66
+ """The parsed capability view for a verified bridge, cached by the
67
+ exact capability bytes (identical metadata parses identically)."""
68
+ key = bridge.capability_json
69
+ cached = _cache.get(key)
70
+ if cached is not None:
71
+ return cached
72
+ parsed = _parse(key)
73
+ _cache.clear()
74
+ _cache[key] = parsed
75
+ return parsed
76
+
77
+
78
+ def _parse(raw: bytes) -> Capabilities:
79
+ try:
80
+ document = json.loads(raw)
81
+ formats = tuple(
82
+ Format(
83
+ name=str(entry["format"]),
84
+ plugin_id=str(entry["plugin_id"]),
85
+ extensions=tuple(str(e) for e in entry["extensions"]),
86
+ can_read=bool(entry["read"]),
87
+ can_write=bool(entry["write"]),
88
+ primary_extension=entry["primary_extension"],
89
+ seekable_input=bool(entry["seekable_input"]),
90
+ text_writer=entry["text_writer"],
91
+ )
92
+ for entry in document["formats"]
93
+ )
94
+ default_output = str(document["default_output_format"])
95
+ limits = {str(name): int(value) for name, value in document["limits"].items()}
96
+ except (KeyError, TypeError, ValueError) as error:
97
+ raise _diagnostics.capabilities_invalid(
98
+ f"{type(error).__name__}: {error}"
99
+ ) from error
100
+ if not formats:
101
+ raise _diagnostics.capabilities_invalid("no formats are declared")
102
+ return Capabilities(formats, default_output, limits)
zenfmt/_convert.py ADDED
@@ -0,0 +1,374 @@
1
+ """Argument validation, reusable policy, and the public conversion call
2
+ (ZDS 0014). Contains no document-format logic: format identity comes from
3
+ native capability metadata only."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path, PurePath
9
+ from typing import Any
10
+
11
+ from . import _capabilities, _diagnostics, _ffi, _loader, _marshal
12
+ from ._limits import LIMIT_TABLE, Limits
13
+ from ._models import Conversion, Format, Strictness
14
+
15
+ _UNSET: Any = object()
16
+
17
+
18
+ def _normalize_strict(value: Any) -> Strictness:
19
+ if value is False or value is None:
20
+ return Strictness.OFF
21
+ if value is True:
22
+ # Bare `--strict` means content, and so does `strict=True`.
23
+ return Strictness.CONTENT
24
+ if isinstance(value, Strictness):
25
+ return value
26
+ if isinstance(value, str):
27
+ try:
28
+ return Strictness(value.lower())
29
+ except ValueError:
30
+ raise _diagnostics.invalid_strict(value) from None
31
+ raise _diagnostics.invalid_strict(value)
32
+
33
+
34
+ def _normalize_limits(value: Any) -> Limits | None:
35
+ if value is None:
36
+ return None
37
+ if isinstance(value, Limits):
38
+ return value
39
+ raise _diagnostics.invalid_limits_argument(value)
40
+
41
+
42
+ def _normalize_bool(argument: str, value: Any) -> bool:
43
+ if isinstance(value, bool):
44
+ return value
45
+ raise _diagnostics.invalid_bool_argument(argument, value)
46
+
47
+
48
+ def _normalize_format(argument: str, value: Any) -> str | None:
49
+ if value is None:
50
+ return None
51
+ if isinstance(value, Format):
52
+ return value.name
53
+ if isinstance(value, str):
54
+ return value
55
+ raise _diagnostics.invalid_format_argument(argument, value)
56
+
57
+
58
+ def _validate_name(name: str) -> str:
59
+ if not isinstance(name, str):
60
+ raise _diagnostics.invalid_name(repr(name), "must be a str display basename")
61
+ if not name:
62
+ raise _diagnostics.invalid_name(name, "must not be empty")
63
+ if any(ch in name for ch in ("/", "\\")):
64
+ raise _diagnostics.invalid_name(name, "must not contain a directory separator")
65
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name):
66
+ raise _diagnostics.invalid_name(name, "must not contain control characters")
67
+ return name
68
+
69
+
70
+ def _sanitize_stream_name(value: Any) -> str | None:
71
+ """The optional display-only ``.name`` attribute of a reader: used
72
+ only when it is already a str, reduced to a basename, never opened."""
73
+ if not isinstance(value, str) or not value:
74
+ return None
75
+ base = PurePath(value).name
76
+ if not base:
77
+ return None
78
+ try:
79
+ return _validate_name(base)
80
+ except ValueError:
81
+ return None
82
+
83
+
84
+ class _Source:
85
+ """One classified conversion source."""
86
+
87
+ __slots__ = ("data", "display_name", "kind", "path")
88
+
89
+ def __init__(
90
+ self,
91
+ kind: str,
92
+ *,
93
+ path: str | bytes | None = None,
94
+ data: bytes | None = None,
95
+ display_name: str | None = None,
96
+ ) -> None:
97
+ self.kind = kind
98
+ self.path = path
99
+ self.data = data
100
+ self.display_name = display_name
101
+
102
+
103
+ def _classify_source(source: Any, name: str | None, max_input_bytes: int) -> _Source:
104
+ if isinstance(source, (str, PurePath, os.PathLike)):
105
+ # A Python str is always a filesystem path, never inline text.
106
+ if name is not None:
107
+ raise _diagnostics.name_with_path_source()
108
+ try:
109
+ path = os.fspath(source)
110
+ except (KeyboardInterrupt, SystemExit, GeneratorExit):
111
+ raise
112
+ except BaseException as error:
113
+ raise _diagnostics.path_protocol_failed("source", error) from error
114
+ display = os.path.basename(path) if isinstance(path, str) else None
115
+ if display is None:
116
+ display = os.path.basename(os.fsdecode(path))
117
+ return _Source("path", path=path, display_name=display)
118
+
119
+ display = _validate_name(name) if name is not None else None
120
+ if isinstance(source, (bytes, bytearray, memoryview)):
121
+ # Mutable or non-contiguous buffers are copied before the GIL is
122
+ # released; immutable bytes pass through.
123
+ data = source if isinstance(source, bytes) else bytes(source)
124
+ return _Source("bytes", data=data, display_name=display or "<memory>")
125
+
126
+ try:
127
+ read = getattr(source, "read", None)
128
+ except (KeyboardInterrupt, SystemExit, GeneratorExit):
129
+ raise
130
+ except BaseException as error:
131
+ raise _diagnostics.reader_failed(error) from error
132
+ if callable(read):
133
+ data = _marshal.read_bounded(source, max_input_bytes)
134
+ try:
135
+ stream_name = getattr(source, "name", None)
136
+ except (KeyboardInterrupt, SystemExit, GeneratorExit):
137
+ raise
138
+ except BaseException:
139
+ stream_name = None
140
+ fallback = _sanitize_stream_name(stream_name)
141
+ return _Source(
142
+ "bytes",
143
+ data=data,
144
+ display_name=display or fallback or "<stream>",
145
+ )
146
+
147
+ raise _diagnostics.invalid_source_type(source)
148
+
149
+
150
+ def _artifact_name(display_name: str, extension: str) -> str:
151
+ """The memory-mode artifact basename: source stem plus the writer's
152
+ primary extension, or ``artifact.<extension>`` when no usable stem
153
+ exists."""
154
+ stem = display_name
155
+ dot = stem.rfind(".")
156
+ if dot > 0:
157
+ stem = stem[:dot]
158
+ if not stem or stem.startswith("<"):
159
+ stem = "artifact"
160
+ return f"{stem}.{extension}"
161
+
162
+
163
+ class Converter:
164
+ """Immutable, reusable conversion policy over the shared native
165
+ bridge. Thread-safe: independent calls may run concurrently on one
166
+ instance, and the GIL is released while native conversion runs."""
167
+
168
+ __slots__ = ("_limits", "_preserve_facets", "_strict")
169
+
170
+ def __init__(
171
+ self,
172
+ *,
173
+ strict: Any = False,
174
+ limits: Limits | None = None,
175
+ preserve_facets: bool = False,
176
+ ) -> None:
177
+ object.__setattr__(self, "_strict", _normalize_strict(strict))
178
+ object.__setattr__(self, "_limits", _normalize_limits(limits))
179
+ object.__setattr__(
180
+ self,
181
+ "_preserve_facets",
182
+ _normalize_bool("preserve_facets", preserve_facets),
183
+ )
184
+
185
+ def __setattr__(self, name: str, value: Any) -> None:
186
+ raise AttributeError("Converter is immutable")
187
+
188
+ @property
189
+ def strict(self) -> Strictness:
190
+ return self._strict
191
+
192
+ @property
193
+ def limits(self) -> Limits | None:
194
+ return self._limits
195
+
196
+ @property
197
+ def preserve_facets(self) -> bool:
198
+ return self._preserve_facets
199
+
200
+ @property
201
+ def formats(self) -> tuple[Format, ...]:
202
+ """The native reader and writer capabilities of this release."""
203
+ return _capabilities.capabilities(_loader.bridge()).formats
204
+
205
+ def convert(
206
+ self,
207
+ source: Any,
208
+ *,
209
+ to: Any = None,
210
+ from_: Any = None,
211
+ output: Any = None,
212
+ name: str | None = None,
213
+ strict: Any = _UNSET,
214
+ limits: Any = _UNSET,
215
+ overwrite: bool = False,
216
+ preserve_facets: Any = _UNSET,
217
+ ) -> Conversion:
218
+ """One conversion using this converter's policy; each keyword
219
+ explicitly supplied here overrides the stored policy for this
220
+ call only."""
221
+ effective_strict = (
222
+ self._strict if strict is _UNSET else _normalize_strict(strict)
223
+ )
224
+ effective_limits = (
225
+ self._limits if limits is _UNSET else _normalize_limits(limits)
226
+ )
227
+ effective_facets = (
228
+ self._preserve_facets
229
+ if preserve_facets is _UNSET
230
+ else _normalize_bool("preserve_facets", preserve_facets)
231
+ )
232
+ effective_overwrite = _normalize_bool("overwrite", overwrite)
233
+ to_value = _normalize_format("to", to)
234
+ from_value = _normalize_format("from_", from_)
235
+ output_path = _normalize_output(output)
236
+
237
+ bridge = _loader.bridge()
238
+ caps = _capabilities.capabilities(bridge)
239
+
240
+ resolved_from = caps.resolve(from_value) if from_value is not None else None
241
+ resolved_to = caps.resolve(to_value) if to_value is not None else None
242
+ if resolved_to is None and output_path is not None:
243
+ # An output suffix may select the writer when `to` is absent.
244
+ suffix = output_path.suffix.removeprefix(".")
245
+ if suffix:
246
+ candidate = caps.resolve(suffix)
247
+ entry = caps.by_name(candidate)
248
+ if entry is not None and entry.can_write:
249
+ resolved_to = candidate
250
+
251
+ max_input = (
252
+ effective_limits.max_input_bytes
253
+ if effective_limits is not None
254
+ else caps.limits.get("max_input_bytes", 512 * 1024 * 1024)
255
+ )
256
+ classified = _classify_source(source, name, max_input)
257
+
258
+ artifact_name = None
259
+ if output_path is None:
260
+ writer = resolved_to or caps.default_output_format
261
+ extension = caps.primary_extension(writer) or "out"
262
+ artifact_name = _artifact_name(
263
+ classified.display_name or "<memory>", extension
264
+ )
265
+
266
+ options = _marshal.options_json(
267
+ input_kind=classified.kind,
268
+ input_name=(
269
+ classified.display_name if classified.kind == "bytes" else None
270
+ ),
271
+ output_kind="path" if output_path is not None else "memory",
272
+ artifact_name=artifact_name,
273
+ from_=resolved_from,
274
+ to=resolved_to,
275
+ strict=effective_strict.value,
276
+ overwrite=effective_overwrite,
277
+ preserve_facets=effective_facets,
278
+ limits=(
279
+ effective_limits.to_dict() if effective_limits is not None else None
280
+ ),
281
+ )
282
+ raw = _marshal.perform(
283
+ bridge,
284
+ options=options,
285
+ input_bytes=classified.data,
286
+ input_path=(
287
+ _marshal.encode_native_path(classified.path)
288
+ if classified.kind == "path"
289
+ else None
290
+ ),
291
+ output_path=(
292
+ _marshal.encode_native_path(os.fspath(output_path))
293
+ if output_path is not None
294
+ else None
295
+ ),
296
+ copy_limits=(
297
+ effective_limits.to_dict()
298
+ if effective_limits is not None
299
+ else {
300
+ name: caps.limits.get(name, default)
301
+ for name, (default, _) in LIMIT_TABLE.items()
302
+ }
303
+ ),
304
+ )
305
+ if raw.status == _ffi.STATUS_INVALID_REQUEST:
306
+ raise _diagnostics.invalid_request()
307
+ if raw.status == _ffi.STATUS_FAILED:
308
+ raise _diagnostics.conversion_error(raw.reports)
309
+ return _marshal.build_conversion(raw, caps, output_path=output_path)
310
+
311
+
312
+ def _normalize_output(output: Any) -> Path | None:
313
+ if output is None:
314
+ return None
315
+ if isinstance(output, (str, PurePath, os.PathLike)):
316
+ # The caller's spelling is preserved rather than silently
317
+ # resolved.
318
+ try:
319
+ return Path(os.fsdecode(os.fspath(output)))
320
+ except (KeyboardInterrupt, SystemExit, GeneratorExit):
321
+ raise
322
+ except BaseException as error:
323
+ raise _diagnostics.path_protocol_failed("output", error) from error
324
+ raise _diagnostics.invalid_output_argument(output)
325
+
326
+
327
+ _default_converter: Converter | None = None
328
+
329
+
330
+ def _default() -> Converter:
331
+ global _default_converter
332
+ if _default_converter is None:
333
+ _default_converter = Converter()
334
+ return _default_converter
335
+
336
+
337
+ def convert(
338
+ source: Any,
339
+ *,
340
+ to: Any = None,
341
+ from_: Any = None,
342
+ output: Any = None,
343
+ name: str | None = None,
344
+ strict: Any = False,
345
+ limits: Limits | None = None,
346
+ overwrite: bool = False,
347
+ preserve_facets: bool = False,
348
+ ) -> Conversion:
349
+ """Converts one document with safe defaults (ZDS 0014).
350
+
351
+ ``source`` is a path, bytes-like object, or binary reader; a Python
352
+ str is always a filesystem path, never inline text. With no
353
+ ``output`` the complete artifact ensemble is returned in memory;
354
+ an ``output`` path selects transactional publication. Failures raise
355
+ :class:`zenfmt.ConversionError` (or a subclass) carrying every native
356
+ report.
357
+ """
358
+ return _default().convert(
359
+ source,
360
+ to=to,
361
+ from_=from_,
362
+ output=output,
363
+ name=name,
364
+ strict=strict,
365
+ limits=limits,
366
+ overwrite=overwrite,
367
+ preserve_facets=preserve_facets,
368
+ )
369
+
370
+
371
+ def formats() -> tuple[Format, ...]:
372
+ """Every reader and writer compiled into this release, in native
373
+ registry order, from capability metadata only."""
374
+ return _capabilities.capabilities(_loader.bridge()).formats