dtxt 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.
dtxt/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """dtxt: schema-centric bidirectional conversion between text and structured data."""
2
+
3
+ from . import backends
4
+ from ._config import configure
5
+ from .d2t import render
6
+ from .infer import InferError, infer_schema
7
+ from .roundtrip import RoundtripResult, check_roundtrip
8
+ from .schema import Schema
9
+ from .t2d import ParseError, parse, parse_many
10
+
11
+ __all__ = [
12
+ "InferError",
13
+ "ParseError",
14
+ "RoundtripResult",
15
+ "Schema",
16
+ "backends",
17
+ "check_roundtrip",
18
+ "configure",
19
+ "infer_schema",
20
+ "parse",
21
+ "parse_many",
22
+ "render",
23
+ ]
dtxt/_config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Global backend configuration, one slot per public function.
2
+
3
+ ``configure()`` sets the default; each function's own ``backend=``
4
+ argument overrides it for that single call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from .backends.base import Backend
12
+
13
+
14
+ @dataclass
15
+ class _Config:
16
+ infer: Backend | None = None
17
+ parse: Backend | None = None
18
+ render: Backend | None = None
19
+
20
+
21
+ _config = _Config()
22
+
23
+
24
+ def configure(
25
+ *,
26
+ infer: Backend | None = None,
27
+ parse: Backend | None = None,
28
+ render: Backend | None = None,
29
+ ) -> None:
30
+ """Set the default backend used by ``infer_schema`` / ``parse`` / ``render``."""
31
+ if infer is not None:
32
+ _config.infer = infer
33
+ if parse is not None:
34
+ _config.parse = parse
35
+ if render is not None:
36
+ _config.render = render
37
+
38
+
39
+ def resolve_backend(function: str, override: Backend | None) -> Backend:
40
+ if override is not None:
41
+ return override
42
+ configured: Backend | None = getattr(_config, function)
43
+ if configured is None:
44
+ raise RuntimeError(
45
+ f"No backend configured for '{function}'. "
46
+ f"Call dtxt.configure({function}=...) or pass backend=... explicitly."
47
+ )
48
+ return configured
49
+
50
+
51
+ def reset() -> None:
52
+ """Clear all configured backends. Mainly useful between tests."""
53
+ _config.infer = None
54
+ _config.parse = None
55
+ _config.render = None
dtxt/_util.py ADDED
@@ -0,0 +1,18 @@
1
+ """Small helpers shared across the T2D / D2T / infer pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def extract_json(raw: str) -> Any:
10
+ """Parse a JSON value out of a model response, tolerating code fences."""
11
+ text = raw.strip()
12
+ if text.startswith("```"):
13
+ text = text.strip("`")
14
+ if "\n" in text:
15
+ first_line, rest = text.split("\n", 1)
16
+ if first_line.strip().isalpha():
17
+ text = rest
18
+ return json.loads(text)
@@ -0,0 +1,33 @@
1
+ """Backend implementations for dtxt.
2
+
3
+ Only the mock backend is imported eagerly. API/local backends
4
+ (``Anthropic``, ``OpenAI``, ``LlamaCpp``) live in optional extras; their
5
+ modules -- and the underlying SDK packages -- are imported lazily on first
6
+ attribute access, so importing ``dtxt`` never requires them to be
7
+ installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ from typing import Any
14
+
15
+ from .base import Backend
16
+ from .mock import MockBackend
17
+
18
+ __all__ = ["Backend", "MockBackend", "Anthropic", "OpenAI", "LlamaCpp"]
19
+
20
+ _LAZY: dict[str, tuple[str, str]] = {
21
+ "Anthropic": (".anthropic", "Anthropic"),
22
+ "OpenAI": (".openai", "OpenAI"),
23
+ "LlamaCpp": (".llamacpp", "LlamaCpp"),
24
+ }
25
+
26
+
27
+ def __getattr__(name: str) -> Any:
28
+ try:
29
+ module_name, attr_name = _LAZY[name]
30
+ except KeyError:
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
32
+ module = importlib.import_module(module_name, __name__)
33
+ return getattr(module, attr_name)
@@ -0,0 +1,114 @@
1
+ """Anthropic API backend: structured output via forced tool use.
2
+
3
+ The ``anthropic`` package is an optional extra (``dtxt[anthropic]``) and is
4
+ imported lazily, only when a client actually needs to be built.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from .base import TOOL_CALLING
13
+
14
+ _INSTALL_HINT = "pip install dtxt[anthropic]"
15
+ _TOOL_NAME = "emit_result"
16
+
17
+
18
+ def _import_anthropic() -> Any:
19
+ try:
20
+ import anthropic
21
+ except ImportError as exc:
22
+ raise ImportError(
23
+ "the 'anthropic' package is required to use dtxt.backends.Anthropic. "
24
+ f"Install it with: {_INSTALL_HINT}"
25
+ ) from exc
26
+ return anthropic
27
+
28
+
29
+ def _build_request_kwargs(
30
+ model: str, prompt: str, schema: dict[str, Any] | None, max_tokens: int
31
+ ) -> dict[str, Any]:
32
+ kwargs: dict[str, Any] = {
33
+ "model": model,
34
+ "max_tokens": max_tokens,
35
+ "messages": [{"role": "user", "content": prompt}],
36
+ }
37
+ if schema is not None:
38
+ kwargs["tools"] = [
39
+ {
40
+ "name": _TOOL_NAME,
41
+ "description": "Emit the result as structured data matching the schema.",
42
+ "input_schema": schema,
43
+ }
44
+ ]
45
+ kwargs["tool_choice"] = {"type": "tool", "name": _TOOL_NAME}
46
+ return kwargs
47
+
48
+
49
+ def _extract_result(message: Any, schema: dict[str, Any] | None) -> str:
50
+ if schema is not None:
51
+ for block in message.content:
52
+ if getattr(block, "type", None) == "tool_use" and getattr(block, "name", None) == (
53
+ _TOOL_NAME
54
+ ):
55
+ return json.dumps(block.input)
56
+ raise RuntimeError("Anthropic response did not include the expected tool_use block")
57
+ return "".join(
58
+ block.text for block in message.content if getattr(block, "type", None) == "text"
59
+ )
60
+
61
+
62
+ class Anthropic:
63
+ """Backend for the Anthropic Messages API.
64
+
65
+ Structured output is obtained by wrapping the JSON Schema as a single
66
+ tool and forcing ``tool_choice`` onto it, so ``tool_use.input`` is the
67
+ extracted object. This is not treated as ``constrained_decoding``: the
68
+ API does not guarantee full schema conformance the way grammar-based
69
+ decoding does, so dtxt still runs it through the retry + validation
70
+ loop.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ model: str,
76
+ *,
77
+ api_key: str | None = None,
78
+ max_tokens: int = 4096,
79
+ client: Any | None = None,
80
+ async_client: Any | None = None,
81
+ ) -> None:
82
+ self._model = model
83
+ self._api_key = api_key
84
+ self._max_tokens = max_tokens
85
+ self._client = client
86
+ self._async_client = async_client
87
+
88
+ def _get_client(self) -> Any:
89
+ if self._client is None:
90
+ anthropic = _import_anthropic()
91
+ kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
92
+ self._client = anthropic.Anthropic(**kwargs)
93
+ return self._client
94
+
95
+ def _get_async_client(self) -> Any:
96
+ if self._async_client is None:
97
+ anthropic = _import_anthropic()
98
+ kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
99
+ self._async_client = anthropic.AsyncAnthropic(**kwargs)
100
+ return self._async_client
101
+
102
+ @property
103
+ def capabilities(self) -> set[str]:
104
+ return {TOOL_CALLING}
105
+
106
+ def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
107
+ kwargs = _build_request_kwargs(self._model, prompt, schema, self._max_tokens)
108
+ message = self._get_client().messages.create(**kwargs)
109
+ return _extract_result(message, schema)
110
+
111
+ async def agenerate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
112
+ kwargs = _build_request_kwargs(self._model, prompt, schema, self._max_tokens)
113
+ message = await self._get_async_client().messages.create(**kwargs)
114
+ return _extract_result(message, schema)
dtxt/backends/base.py ADDED
@@ -0,0 +1,27 @@
1
+ """Backend abstraction: the interface every dtxt backend implements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+ CONSTRAINED_DECODING = "constrained_decoding"
8
+ JSON_MODE = "json_mode"
9
+ TOOL_CALLING = "tool_calling"
10
+
11
+
12
+ @runtime_checkable
13
+ class Backend(Protocol):
14
+ """Anything that can turn a prompt into text, optionally schema-guided.
15
+
16
+ ``capabilities`` tells the caller which execution path to take:
17
+
18
+ - ``constrained_decoding``: the backend enforces the JSON schema at the
19
+ grammar level, so callers may skip syntactic validation and only run
20
+ semantic checks.
21
+ - otherwise: callers must retry and validate the output themselves.
22
+ """
23
+
24
+ def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str: ...
25
+
26
+ @property
27
+ def capabilities(self) -> set[str]: ...
@@ -0,0 +1,126 @@
1
+ """llama.cpp backend: local GGUF models with GBNF-constrained decoding.
2
+
3
+ ``llama-cpp-python`` is a heavy, optional extra (``dtxt[llamacpp]``) and is
4
+ imported lazily. Generation constrains JSON output at the grammar level
5
+ via ``response_format``. Schema constructs GBNF can't reliably express
6
+ (``format``, ``pattern``, deep nesting) are stripped from the
7
+ grammar-facing copy of the schema; the original schema is left untouched
8
+ for dtxt's normal post-hoc jsonschema validation. That is the two-stage
9
+ split: what the grammar guarantees during decoding, and what gets checked
10
+ afterwards.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, cast
16
+
17
+ from .base import CONSTRAINED_DECODING
18
+
19
+ _INSTALL_HINT = "pip install dtxt[llamacpp]"
20
+
21
+ DEFAULT_MAX_GRAMMAR_DEPTH = 6
22
+ DEFAULT_PROMPT_TEMPLATE = "{prompt}"
23
+
24
+ _UNGRAMMARABLE_KEYWORDS = ("format", "pattern")
25
+
26
+
27
+ def _import_llama_cpp() -> Any:
28
+ try:
29
+ import llama_cpp
30
+ except ImportError as exc:
31
+ raise ImportError(
32
+ "the 'llama-cpp-python' package is required to use dtxt.backends.LlamaCpp. "
33
+ f"Install it with: {_INSTALL_HINT}"
34
+ ) from exc
35
+ return llama_cpp
36
+
37
+
38
+ def grammar_safe_schema(
39
+ schema: dict[str, Any], *, max_depth: int = DEFAULT_MAX_GRAMMAR_DEPTH
40
+ ) -> dict[str, Any]:
41
+ """Return a copy of ``schema`` with GBNF-unfriendly constructs removed.
42
+
43
+ Drops ``format``/``pattern`` everywhere, and replaces ``object``/
44
+ ``array`` schemas past ``max_depth`` nested levels with an
45
+ unconstrained placeholder of the same type. The caller is expected to
46
+ still validate the original schema post-hoc.
47
+ """
48
+ return cast(dict[str, Any], _strip(schema, depth=0, max_depth=max_depth))
49
+
50
+
51
+ def _strip(node: Any, *, depth: int, max_depth: int) -> Any:
52
+ if not isinstance(node, dict):
53
+ return node
54
+
55
+ if depth > max_depth:
56
+ node_type = node.get("type")
57
+ if node_type in ("object", "array"):
58
+ return {"type": node_type}
59
+ return dict(node)
60
+
61
+ stripped = {key: value for key, value in node.items() if key not in _UNGRAMMARABLE_KEYWORDS}
62
+ if "properties" in stripped:
63
+ stripped["properties"] = {
64
+ name: _strip(prop, depth=depth + 1, max_depth=max_depth)
65
+ for name, prop in stripped["properties"].items()
66
+ }
67
+ if "items" in stripped:
68
+ stripped["items"] = _strip(stripped["items"], depth=depth + 1, max_depth=max_depth)
69
+ return stripped
70
+
71
+
72
+ class LlamaCpp:
73
+ """Backend for local GGUF models via ``llama-cpp-python``.
74
+
75
+ Small local models are prompt-sensitive, so the prompt can be wrapped
76
+ in a model-specific chat template by passing ``prompt_template`` (a
77
+ ``str.format`` template with a single ``{prompt}`` placeholder).
78
+
79
+ In-process inference assumes a single stream: batches are processed
80
+ sequentially (no ``agenerate``), so llama.cpp's prompt cache is reused
81
+ across calls sharing a prefix instead of contending for one model
82
+ instance in parallel.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ model_path: str,
88
+ *,
89
+ n_ctx: int = 4096,
90
+ max_tokens: int = 1024,
91
+ max_grammar_depth: int = DEFAULT_MAX_GRAMMAR_DEPTH,
92
+ prompt_template: str = DEFAULT_PROMPT_TEMPLATE,
93
+ llama: Any | None = None,
94
+ **llama_kwargs: Any,
95
+ ) -> None:
96
+ self._model_path = model_path
97
+ self._n_ctx = n_ctx
98
+ self._max_tokens = max_tokens
99
+ self._max_grammar_depth = max_grammar_depth
100
+ self._prompt_template = prompt_template
101
+ self._llama_kwargs = llama_kwargs
102
+ self._llama = llama
103
+
104
+ def _get_llama(self) -> Any:
105
+ if self._llama is None:
106
+ llama_cpp = _import_llama_cpp()
107
+ self._llama = llama_cpp.Llama(
108
+ model_path=self._model_path, n_ctx=self._n_ctx, **self._llama_kwargs
109
+ )
110
+ return self._llama
111
+
112
+ @property
113
+ def capabilities(self) -> set[str]:
114
+ return {CONSTRAINED_DECODING}
115
+
116
+ def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
117
+ llama = self._get_llama()
118
+ kwargs: dict[str, Any] = {
119
+ "messages": [{"role": "user", "content": self._prompt_template.format(prompt=prompt)}],
120
+ "max_tokens": self._max_tokens,
121
+ }
122
+ if schema is not None:
123
+ safe_schema = grammar_safe_schema(schema, max_depth=self._max_grammar_depth)
124
+ kwargs["response_format"] = {"type": "json_object", "schema": safe_schema}
125
+ response = llama.create_chat_completion(**kwargs)
126
+ return str(response["choices"][0]["message"]["content"])
dtxt/backends/mock.py ADDED
@@ -0,0 +1,58 @@
1
+ """A deterministic, in-process backend used for unit tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Iterator
7
+ from typing import Any
8
+
9
+ _TYPE_PLACEHOLDERS: dict[str, Any] = {
10
+ "string": "",
11
+ "integer": 0,
12
+ "number": 0.0,
13
+ "boolean": False,
14
+ "array": [],
15
+ "object": {},
16
+ }
17
+
18
+
19
+ def _placeholder_for(schema: dict[str, Any]) -> dict[str, Any]:
20
+ properties = schema.get("properties", {})
21
+ return {
22
+ name: _TYPE_PLACEHOLDERS.get(prop.get("type"), None) for name, prop in properties.items()
23
+ }
24
+
25
+
26
+ class MockBackend:
27
+ """No network, no randomness: responses are canned or computed locally.
28
+
29
+ Every call is recorded in ``calls`` so tests can assert on the prompts
30
+ a caller produced.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ responses: list[str] | None = None,
36
+ *,
37
+ generate_fn: Callable[[str, dict[str, Any] | None], str] | None = None,
38
+ capabilities: set[str] | None = None,
39
+ ) -> None:
40
+ self._responses: Iterator[str] | None = iter(responses) if responses is not None else None
41
+ self._generate_fn = generate_fn
42
+ self._capabilities = capabilities if capabilities is not None else set()
43
+ self.calls: list[tuple[str, dict[str, Any] | None]] = []
44
+
45
+ def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
46
+ self.calls.append((prompt, schema))
47
+ if self._generate_fn is not None:
48
+ return self._generate_fn(prompt, schema)
49
+ if self._responses is not None:
50
+ try:
51
+ return next(self._responses)
52
+ except StopIteration as exc:
53
+ raise RuntimeError("MockBackend ran out of canned responses") from exc
54
+ return json.dumps(_placeholder_for(schema)) if schema is not None else ""
55
+
56
+ @property
57
+ def capabilities(self) -> set[str]:
58
+ return self._capabilities
@@ -0,0 +1,96 @@
1
+ """OpenAI API backend: structured outputs via ``response_format``.
2
+
3
+ The ``openai`` package is an optional extra (``dtxt[openai]``) and is
4
+ imported lazily, only when a client actually needs to be built.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from .base import JSON_MODE
12
+
13
+ _INSTALL_HINT = "pip install dtxt[openai]"
14
+
15
+
16
+ def _import_openai() -> Any:
17
+ try:
18
+ import openai
19
+ except ImportError as exc:
20
+ raise ImportError(
21
+ "the 'openai' package is required to use dtxt.backends.OpenAI. "
22
+ f"Install it with: {_INSTALL_HINT}"
23
+ ) from exc
24
+ return openai
25
+
26
+
27
+ def _build_request_kwargs(model: str, prompt: str, schema: dict[str, Any] | None) -> dict[str, Any]:
28
+ kwargs: dict[str, Any] = {
29
+ "model": model,
30
+ "messages": [{"role": "user", "content": prompt}],
31
+ }
32
+ if schema is not None:
33
+ kwargs["response_format"] = {
34
+ "type": "json_schema",
35
+ "json_schema": {"name": "dtxt_result", "schema": schema},
36
+ }
37
+ return kwargs
38
+
39
+
40
+ def _extract_result(response: Any) -> str:
41
+ content = response.choices[0].message.content
42
+ if content is None:
43
+ raise RuntimeError("OpenAI response contained no content")
44
+ return str(content)
45
+
46
+
47
+ class OpenAI:
48
+ """Backend for the OpenAI Chat Completions API.
49
+
50
+ Uses ``response_format={"type": "json_schema", ...}`` (structured
51
+ outputs) when a schema is given. This is not treated as
52
+ ``constrained_decoding``: OpenAI's guarantee is on JSON syntax, not on
53
+ every schema keyword dtxt supports, so dtxt still runs it through the
54
+ retry + validation loop.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ model: str,
60
+ *,
61
+ api_key: str | None = None,
62
+ client: Any | None = None,
63
+ async_client: Any | None = None,
64
+ ) -> None:
65
+ self._model = model
66
+ self._api_key = api_key
67
+ self._client = client
68
+ self._async_client = async_client
69
+
70
+ def _get_client(self) -> Any:
71
+ if self._client is None:
72
+ openai = _import_openai()
73
+ kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
74
+ self._client = openai.OpenAI(**kwargs)
75
+ return self._client
76
+
77
+ def _get_async_client(self) -> Any:
78
+ if self._async_client is None:
79
+ openai = _import_openai()
80
+ kwargs: dict[str, Any] = {"api_key": self._api_key} if self._api_key else {}
81
+ self._async_client = openai.AsyncOpenAI(**kwargs)
82
+ return self._async_client
83
+
84
+ @property
85
+ def capabilities(self) -> set[str]:
86
+ return {JSON_MODE}
87
+
88
+ def generate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
89
+ kwargs = _build_request_kwargs(self._model, prompt, schema)
90
+ response = self._get_client().chat.completions.create(**kwargs)
91
+ return _extract_result(response)
92
+
93
+ async def agenerate(self, prompt: str, *, schema: dict[str, Any] | None = None) -> str:
94
+ kwargs = _build_request_kwargs(self._model, prompt, schema)
95
+ response = await self._get_async_client().chat.completions.create(**kwargs)
96
+ return _extract_result(response)
dtxt/d2t.py ADDED
@@ -0,0 +1,53 @@
1
+ """D2T: object -> text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._config import resolve_backend
8
+ from .backends.base import Backend
9
+ from .prompts import build_d2t_prompt
10
+ from .schema import Schema
11
+
12
+
13
+ def _field_guidance(schema: Schema) -> str:
14
+ lines: list[str] = []
15
+ for name in schema.properties:
16
+ parts: list[str] = []
17
+ description = schema.field_description(name)
18
+ if description:
19
+ parts.append(description)
20
+ examples = schema.field_examples(name)
21
+ if examples:
22
+ parts.append(f"examples: {examples}")
23
+ style = schema.field_style(name)
24
+ if style:
25
+ parts.append(f"style: {style}")
26
+ if parts:
27
+ lines.append(f"- {name}: {'; '.join(parts)}")
28
+ return "\n".join(lines) if lines else "(none)"
29
+
30
+
31
+ def render(
32
+ obj: dict[str, Any],
33
+ schema: Schema,
34
+ *,
35
+ style: str | None = None,
36
+ backend: Backend | None = None,
37
+ ) -> str:
38
+ """Convert ``obj`` into text, guided by ``schema``'s ``x-dtxt-*`` metadata.
39
+
40
+ ``style`` overrides the schema's own ``x-dtxt-style`` (its root-level
41
+ style hint) for this call; per-field style hints still apply on top of
42
+ either one.
43
+ """
44
+ resolved = resolve_backend("render", backend)
45
+ json_schema = schema.to_json_schema()
46
+ effective_style = style if style is not None else schema.style
47
+ prompt = build_d2t_prompt(
48
+ obj,
49
+ json_schema,
50
+ style=effective_style or "(none)",
51
+ field_guidance=_field_guidance(schema),
52
+ )
53
+ return resolved.generate(prompt)
dtxt/infer.py ADDED
@@ -0,0 +1,96 @@
1
+ """infer_schema: schema inference via sampling + merge.
2
+
3
+ Texts are sampled in small batches (kind to small context windows); each
4
+ batch yields a candidate schema, and a field is kept in the merged schema
5
+ only if it appears in at least ``min_coverage`` of the candidates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import Counter
12
+ from typing import Any
13
+
14
+ from ._config import resolve_backend
15
+ from ._util import extract_json
16
+ from .backends.base import Backend
17
+ from .prompts import build_infer_prompt
18
+ from .schema import Schema
19
+
20
+ DEFAULT_BATCH_SIZE = 5
21
+ DEFAULT_MIN_COVERAGE = 0.6
22
+
23
+
24
+ class InferError(Exception):
25
+ """Raised when schema inference cannot produce a usable schema."""
26
+
27
+
28
+ def _batched(items: list[str], size: int) -> list[list[str]]:
29
+ return [items[i : i + size] for i in range(0, len(items), size)]
30
+
31
+
32
+ def _candidate_schema(batch: list[str], backend: Backend) -> dict[str, Any] | None:
33
+ prompt = build_infer_prompt(batch)
34
+ raw = backend.generate(prompt)
35
+ try:
36
+ candidate = extract_json(raw)
37
+ except json.JSONDecodeError:
38
+ return None
39
+ if not isinstance(candidate, dict) or not isinstance(candidate.get("properties"), dict):
40
+ return None
41
+ return candidate
42
+
43
+
44
+ def _merge_candidates(candidates: list[dict[str, Any]], min_coverage: float) -> dict[str, Any]:
45
+ n = len(candidates)
46
+ field_types: dict[str, Counter[str]] = {}
47
+ field_count: Counter[str] = Counter()
48
+
49
+ for candidate in candidates:
50
+ for name, field_schema in candidate["properties"].items():
51
+ field_count[name] += 1
52
+ field_types.setdefault(name, Counter())[field_schema.get("type", "string")] += 1
53
+
54
+ properties: dict[str, Any] = {}
55
+ required: list[str] = []
56
+ for name, count in field_count.items():
57
+ coverage = count / n
58
+ if coverage < min_coverage:
59
+ continue
60
+ most_common_type, _ = field_types[name].most_common(1)[0]
61
+ properties[name] = {"type": most_common_type}
62
+ if coverage >= 1.0:
63
+ required.append(name)
64
+
65
+ return {"type": "object", "properties": properties, "required": sorted(required)}
66
+
67
+
68
+ def infer_schema(
69
+ texts: list[str],
70
+ *,
71
+ backend: Backend | None = None,
72
+ batch_size: int = DEFAULT_BATCH_SIZE,
73
+ min_coverage: float = DEFAULT_MIN_COVERAGE,
74
+ ) -> Schema:
75
+ """Infer a JSON Schema shared by ``texts``."""
76
+ if not texts:
77
+ raise InferError("cannot infer a schema from an empty text collection")
78
+
79
+ resolved = resolve_backend("infer", backend)
80
+ batches = _batched(texts, batch_size)
81
+
82
+ candidates = [
83
+ candidate
84
+ for candidate in (_candidate_schema(batch, resolved) for batch in batches)
85
+ if candidate is not None
86
+ ]
87
+ if not candidates:
88
+ raise InferError("backend did not return any usable candidate schema")
89
+
90
+ merged = _merge_candidates(candidates, min_coverage)
91
+ if not merged["properties"]:
92
+ raise InferError(
93
+ f"no fields met the min_coverage={min_coverage} threshold across "
94
+ f"{len(candidates)} candidate schema(s)"
95
+ )
96
+ return Schema(merged)
dtxt/prompts.py ADDED
@@ -0,0 +1,101 @@
1
+ """Prompt templates for T2D, D2T, and schema inference.
2
+
3
+ Kept as plain module constants (rather than inline f-strings in the
4
+ pipeline modules) so a caller or a backend can override them -- e.g. a
5
+ small local model with different prompt sensitivities -- without touching
6
+ ``t2d.py`` / ``d2t.py`` / ``infer.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ T2D_TEMPLATE = """\
15
+ You convert unstructured text into a single JSON object that strictly follows the given JSON Schema.
16
+
17
+ # JSON Schema
18
+ {schema}
19
+
20
+ # Text
21
+ {text}
22
+
23
+ Respond with ONLY the JSON object. Do not include markdown fences or commentary.
24
+ If a field's value cannot be determined from the text, set it to null."""
25
+
26
+ T2D_RETRY_TEMPLATE = """\
27
+ {base_prompt}
28
+
29
+ # Previous attempt
30
+ {previous_output}
31
+
32
+ # Validation errors
33
+ {errors}
34
+
35
+ Fix the JSON object so it satisfies the schema and passes validation.
36
+ Respond with ONLY the corrected JSON object."""
37
+
38
+ D2T_TEMPLATE = """\
39
+ You convert a JSON object into natural, fluent text that expresses every non-null field.
40
+
41
+ # JSON Schema
42
+ {schema}
43
+
44
+ # Overall style
45
+ {style}
46
+
47
+ # Field guidance
48
+ {field_guidance}
49
+
50
+ # Object
51
+ {obj}
52
+
53
+ Write the text now. Do not include the JSON, labels, or commentary -- only the resulting text."""
54
+
55
+ INFER_TEMPLATE = """\
56
+ You infer a JSON Schema that describes the common structure shared by the following texts.
57
+
58
+ # Texts
59
+ {texts}
60
+
61
+ Respond with ONLY a JSON Schema object (type "object", with "properties" and "required").
62
+ Do not include markdown fences or commentary."""
63
+
64
+
65
+ def build_t2d_prompt(text: str, schema: dict[str, Any], *, template: str = T2D_TEMPLATE) -> str:
66
+ return template.format(schema=json.dumps(schema, ensure_ascii=False, indent=2), text=text)
67
+
68
+
69
+ def build_t2d_retry_prompt(
70
+ base_prompt: str,
71
+ previous_output: str,
72
+ errors: list[str],
73
+ *,
74
+ template: str = T2D_RETRY_TEMPLATE,
75
+ ) -> str:
76
+ return template.format(
77
+ base_prompt=base_prompt,
78
+ previous_output=previous_output,
79
+ errors="\n".join(f"- {error}" for error in errors),
80
+ )
81
+
82
+
83
+ def build_d2t_prompt(
84
+ obj: dict[str, Any],
85
+ schema: dict[str, Any],
86
+ *,
87
+ style: str = "(none)",
88
+ field_guidance: str = "(none)",
89
+ template: str = D2T_TEMPLATE,
90
+ ) -> str:
91
+ return template.format(
92
+ schema=json.dumps(schema, ensure_ascii=False, indent=2),
93
+ obj=json.dumps(obj, ensure_ascii=False, indent=2),
94
+ style=style,
95
+ field_guidance=field_guidance,
96
+ )
97
+
98
+
99
+ def build_infer_prompt(texts: list[str], *, template: str = INFER_TEMPLATE) -> str:
100
+ joined = "\n\n".join(f"[{i + 1}] {text}" for i, text in enumerate(texts))
101
+ return template.format(texts=joined)
dtxt/py.typed ADDED
File without changes
dtxt/roundtrip.py ADDED
@@ -0,0 +1,54 @@
1
+ """round-trip verification: ``parse(render(obj)) ≈ obj``.
2
+
3
+ Treated as a first-class feature: this is the main way to tell whether a
4
+ schema + backend pair actually preserves information through D2T then T2D.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from .backends.base import Backend
13
+ from .d2t import render
14
+ from .schema import Schema
15
+ from .t2d import parse
16
+
17
+
18
+ @dataclass
19
+ class RoundtripResult:
20
+ original: dict[str, Any]
21
+ rendered_text: str
22
+ reparsed: dict[str, Any]
23
+ mismatches: dict[str, tuple[Any, Any]] = field(default_factory=dict)
24
+
25
+ @property
26
+ def ok(self) -> bool:
27
+ return not self.mismatches
28
+
29
+
30
+ def check_roundtrip(
31
+ obj: dict[str, Any],
32
+ schema: Schema,
33
+ *,
34
+ render_backend: Backend | None = None,
35
+ parse_backend: Backend | None = None,
36
+ ) -> RoundtripResult:
37
+ """Render ``obj`` to text, parse it back, and diff against ``obj``.
38
+
39
+ Comparison is restricted to ``schema``'s declared fields, since ``parse``
40
+ only ever populates those.
41
+ """
42
+ text = render(obj, schema, backend=render_backend)
43
+ reparsed = parse(text, schema, backend=parse_backend)
44
+
45
+ mismatches: dict[str, tuple[Any, Any]] = {}
46
+ for key in schema.properties:
47
+ original_value = obj.get(key)
48
+ reparsed_value = reparsed.get(key)
49
+ if original_value != reparsed_value:
50
+ mismatches[key] = (original_value, reparsed_value)
51
+
52
+ return RoundtripResult(
53
+ original=obj, rendered_text=text, reparsed=reparsed, mismatches=mismatches
54
+ )
dtxt/schema.py ADDED
@@ -0,0 +1,116 @@
1
+ """Schema: the JSON-Schema-compatible internal representation used across dtxt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any, Optional
7
+
8
+ import jsonschema
9
+ from pydantic import BaseModel, create_model
10
+
11
+ _EXT_DESCRIPTION = "x-dtxt-description"
12
+ _EXT_EXAMPLES = "x-dtxt-examples"
13
+ _EXT_STYLE = "x-dtxt-style"
14
+
15
+ _JSON_TYPE_TO_PY: dict[str, Any] = {
16
+ "string": str,
17
+ "integer": int,
18
+ "number": float,
19
+ "boolean": bool,
20
+ "array": list,
21
+ "object": dict,
22
+ }
23
+
24
+
25
+ class Schema:
26
+ """A JSON-Schema-compatible schema.
27
+
28
+ D2T description metadata (description, examples, style hints) is carried
29
+ as ``x-dtxt-*`` extension keywords on field schemas so it round-trips
30
+ through plain JSON Schema tooling.
31
+ """
32
+
33
+ def __init__(self, json_schema: dict[str, Any]) -> None:
34
+ self._json_schema = copy.deepcopy(json_schema)
35
+
36
+ def __repr__(self) -> str:
37
+ return f"Schema({self._json_schema!r})"
38
+
39
+ def __eq__(self, other: object) -> bool:
40
+ if not isinstance(other, Schema):
41
+ return NotImplemented
42
+ return self._json_schema == other._json_schema
43
+
44
+ @classmethod
45
+ def from_pydantic(cls, model: type[BaseModel]) -> Schema:
46
+ return cls(model.model_json_schema())
47
+
48
+ @classmethod
49
+ def from_dict(cls, json_schema: dict[str, Any]) -> Schema:
50
+ return cls(json_schema)
51
+
52
+ def to_json_schema(self) -> dict[str, Any]:
53
+ return copy.deepcopy(self._json_schema)
54
+
55
+ def to_pydantic(self, name: str = "DtxtModel") -> type[BaseModel]:
56
+ """Build a best-effort Pydantic model from the top-level properties.
57
+
58
+ This covers the common flat-record case; deeply nested or
59
+ format-constrained schemas keep their full fidelity in
60
+ ``to_json_schema()`` instead.
61
+ """
62
+ fields: dict[str, Any] = {}
63
+ required = set(self.required)
64
+ for field_name, field_schema in self.properties.items():
65
+ py_type = _JSON_TYPE_TO_PY.get(field_schema.get("type"), Any)
66
+ if field_name in required:
67
+ fields[field_name] = (py_type, ...)
68
+ else:
69
+ fields[field_name] = (Optional[py_type], None) # noqa: UP045
70
+ return create_model(name, **fields)
71
+
72
+ @property
73
+ def properties(self) -> dict[str, Any]:
74
+ return dict(self._json_schema.get("properties", {}))
75
+
76
+ @property
77
+ def required(self) -> list[str]:
78
+ return list(self._json_schema.get("required", []))
79
+
80
+ @property
81
+ def style(self) -> str | None:
82
+ """Schema-wide D2T style hint (``x-dtxt-style`` on the schema root).
83
+
84
+ Distinct from ``field_style()``, which is per-field. ``render()``'s
85
+ ``style=`` argument overrides this for a single call.
86
+ """
87
+ result = self._json_schema.get(_EXT_STYLE)
88
+ return result if isinstance(result, str) else None
89
+
90
+ def field_description(self, field_name: str) -> str | None:
91
+ result = self.properties.get(field_name, {}).get(_EXT_DESCRIPTION)
92
+ return result if isinstance(result, str) else None
93
+
94
+ def field_examples(self, field_name: str) -> list[Any]:
95
+ result = self.properties.get(field_name, {}).get(_EXT_EXAMPLES, [])
96
+ return list(result) if isinstance(result, list) else []
97
+
98
+ def field_style(self, field_name: str) -> str | None:
99
+ result = self.properties.get(field_name, {}).get(_EXT_STYLE)
100
+ return result if isinstance(result, str) else None
101
+
102
+ def validate(self, obj: dict[str, Any]) -> None:
103
+ jsonschema.validate(instance=obj, schema=self._json_schema)
104
+
105
+ def is_valid(self, obj: dict[str, Any]) -> bool:
106
+ try:
107
+ self.validate(obj)
108
+ except jsonschema.ValidationError:
109
+ return False
110
+ return True
111
+
112
+ def iter_errors(self, obj: dict[str, Any]) -> list[str]:
113
+ validator_cls = jsonschema.validators.validator_for(self._json_schema)
114
+ validator_cls.check_schema(self._json_schema)
115
+ validator = validator_cls(self._json_schema)
116
+ return [error.message for error in validator.iter_errors(obj)]
dtxt/t2d.py ADDED
@@ -0,0 +1,166 @@
1
+ """T2D: text -> schema-conformant object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ from ._config import resolve_backend
10
+ from ._util import extract_json
11
+ from .backends.base import Backend
12
+ from .prompts import build_t2d_prompt, build_t2d_retry_prompt
13
+ from .schema import Schema
14
+
15
+ DEFAULT_MAX_RETRIES = 3
16
+ DEFAULT_MAX_CONCURRENCY = 8
17
+
18
+
19
+ class ParseError(Exception):
20
+ """Raised when a backend fails to produce a schema-conformant object."""
21
+
22
+
23
+ def parse(
24
+ text: str,
25
+ schema: Schema,
26
+ *,
27
+ backend: Backend | None = None,
28
+ max_retries: int = DEFAULT_MAX_RETRIES,
29
+ ) -> dict[str, Any]:
30
+ """Convert ``text`` into an object conforming to ``schema``.
31
+
32
+ Missing or unextractable fields are represented as ``None``.
33
+ """
34
+ resolved = resolve_backend("parse", backend)
35
+ json_schema = schema.to_json_schema()
36
+ prompt = build_t2d_prompt(text, json_schema)
37
+
38
+ # A constrained-decoding backend only guarantees the grammar-facing part
39
+ # of the schema (see backends/llamacpp.py's two-stage split); keywords
40
+ # like `format`/`pattern` still need this retry + validation loop, so
41
+ # `max_retries` applies uniformly regardless of backend capabilities.
42
+ attempts = max_retries
43
+
44
+ last_error: Exception = ParseError("backend produced no output")
45
+ raw = resolved.generate(prompt, schema=json_schema)
46
+ for attempt in range(attempts):
47
+ is_last_attempt = attempt == attempts - 1
48
+ try:
49
+ obj = extract_json(raw)
50
+ except json.JSONDecodeError as exc:
51
+ last_error = exc
52
+ if is_last_attempt:
53
+ break
54
+ raw = resolved.generate(
55
+ build_t2d_retry_prompt(prompt, raw, [f"invalid JSON: {exc}"]),
56
+ schema=json_schema,
57
+ )
58
+ continue
59
+
60
+ errors = schema.iter_errors(obj)
61
+ if not errors:
62
+ return dict(obj)
63
+ last_error = ValueError("; ".join(errors))
64
+ if is_last_attempt:
65
+ break
66
+ raw = resolved.generate(build_t2d_retry_prompt(prompt, raw, errors), schema=json_schema)
67
+
68
+ raise ParseError(
69
+ f"failed to parse a schema-conformant object after {attempts} attempt(s): {last_error}"
70
+ ) from last_error
71
+
72
+
73
+ def parse_many(
74
+ texts: list[str],
75
+ schema: Schema,
76
+ *,
77
+ backend: Backend | None = None,
78
+ max_retries: int = DEFAULT_MAX_RETRIES,
79
+ max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
80
+ ) -> list[dict[str, Any]]:
81
+ """Batch version of ``parse``.
82
+
83
+ When the resolved backend exposes an ``agenerate`` coroutine (API
84
+ backends such as Anthropic/OpenAI), texts are parsed concurrently via
85
+ asyncio, bounded by ``max_concurrency`` to avoid tripping rate limits.
86
+ Otherwise falls back to sequential ``parse`` calls (e.g. llama.cpp,
87
+ which assumes a single in-process stream).
88
+ """
89
+ resolved = resolve_backend("parse", backend)
90
+ if not texts:
91
+ return []
92
+ if getattr(resolved, "agenerate", None) is not None:
93
+ return asyncio.run(_parse_many_async(texts, schema, resolved, max_retries, max_concurrency))
94
+ return [parse(text, schema, backend=resolved, max_retries=max_retries) for text in texts]
95
+
96
+
97
+ async def _parse_many_async(
98
+ texts: list[str],
99
+ schema: Schema,
100
+ backend: Any,
101
+ max_retries: int,
102
+ max_concurrency: int,
103
+ ) -> list[dict[str, Any]]:
104
+ semaphore = asyncio.Semaphore(max_concurrency)
105
+
106
+ async def _bounded(text: str) -> dict[str, Any]:
107
+ async with semaphore:
108
+ return await _parse_async(text, schema, backend, max_retries)
109
+
110
+ # `return_exceptions=True` lets every text finish (success or failure)
111
+ # instead of raising on the first failure and leaving other in-flight
112
+ # requests to be torn down as dangling tasks.
113
+ results: list[dict[str, Any] | BaseException] = await asyncio.gather(
114
+ *(_bounded(text) for text in texts), return_exceptions=True
115
+ )
116
+
117
+ failures = [(i, r) for i, r in enumerate(results) if isinstance(r, BaseException)]
118
+ if failures:
119
+ first_index, first_error = failures[0]
120
+ raise ParseError(
121
+ f"parse_many failed for {len(failures)}/{len(texts)} text(s); "
122
+ f"first failure at index {first_index}: {first_error}"
123
+ ) from first_error
124
+
125
+ return [result for result in results if isinstance(result, dict)]
126
+
127
+
128
+ async def _parse_async(
129
+ text: str,
130
+ schema: Schema,
131
+ backend: Any,
132
+ max_retries: int,
133
+ ) -> dict[str, Any]:
134
+ json_schema = schema.to_json_schema()
135
+ prompt = build_t2d_prompt(text, json_schema)
136
+ attempts = max_retries
137
+
138
+ last_error: Exception = ParseError("backend produced no output")
139
+ raw = await backend.agenerate(prompt, schema=json_schema)
140
+ for attempt in range(attempts):
141
+ is_last_attempt = attempt == attempts - 1
142
+ try:
143
+ obj = extract_json(raw)
144
+ except json.JSONDecodeError as exc:
145
+ last_error = exc
146
+ if is_last_attempt:
147
+ break
148
+ raw = await backend.agenerate(
149
+ build_t2d_retry_prompt(prompt, raw, [f"invalid JSON: {exc}"]),
150
+ schema=json_schema,
151
+ )
152
+ continue
153
+
154
+ errors = schema.iter_errors(obj)
155
+ if not errors:
156
+ return dict(obj)
157
+ last_error = ValueError("; ".join(errors))
158
+ if is_last_attempt:
159
+ break
160
+ raw = await backend.agenerate(
161
+ build_t2d_retry_prompt(prompt, raw, errors), schema=json_schema
162
+ )
163
+
164
+ raise ParseError(
165
+ f"failed to parse a schema-conformant object after {attempts} attempt(s): {last_error}"
166
+ ) from last_error
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: dtxt
3
+ Version: 0.1.0
4
+ Summary: Schema-centric bidirectional conversion between text and structured data
5
+ Author: pillyshi
6
+ Author-email: pillyshi <pillyshi21@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: pydantic>=2.0
18
+ Requires-Dist: jsonschema>=4.0
19
+ Requires-Dist: dtxt[openai,anthropic,llamacpp] ; extra == 'all'
20
+ Requires-Dist: anthropic>=0.40 ; extra == 'anthropic'
21
+ Requires-Dist: llama-cpp-python>=0.3 ; extra == 'llamacpp'
22
+ Requires-Dist: openai>=1.0 ; extra == 'openai'
23
+ Requires-Python: >=3.10
24
+ Project-URL: Homepage, https://github.com/pillyshi/dtxt
25
+ Project-URL: Repository, https://github.com/pillyshi/dtxt
26
+ Project-URL: Issues, https://github.com/pillyshi/dtxt/issues
27
+ Provides-Extra: all
28
+ Provides-Extra: anthropic
29
+ Provides-Extra: llamacpp
30
+ Provides-Extra: openai
31
+ Description-Content-Type: text/markdown
32
+
33
+ # dtxt
34
+
35
+ Schema-centric bidirectional conversion between text and structured data.
36
+
37
+ `dtxt` is not related to [`dtx`](https://pypi.org/project/dtx/) (an AI red-teaming tool).
38
+
39
+ ## Core features
40
+
41
+ 1. **Schema inference** (`infer_schema`): derive a schema from a collection of texts
42
+ 2. **T2D** (`parse`): convert text into a schema-conformant object
43
+ 3. **D2T** (`render`): convert an object into text
44
+ 4. **Round-trip verification** (`check_roundtrip`): check that
45
+ `parse(render(obj)) ≈ obj` for a given schema and backend
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install dtxt # core only
51
+ pip install dtxt[anthropic] # + Anthropic backend
52
+ pip install dtxt[openai] # + OpenAI backend
53
+ pip install dtxt[llamacpp] # + local GGUF models via llama.cpp
54
+ pip install dtxt[all] # everything
55
+ ```
56
+
57
+ The core package depends only on `pydantic` and `jsonschema`. Backends are
58
+ optional extras, imported lazily.
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ import dtxt
64
+ from dtxt import Schema
65
+ from dtxt.backends import MockBackend
66
+
67
+ schema = Schema({
68
+ "type": "object",
69
+ "properties": {
70
+ "name": {"type": "string", "x-dtxt-description": "the person's full name"},
71
+ "age": {"type": "integer"},
72
+ },
73
+ "required": ["name", "age"],
74
+ })
75
+
76
+ # Backends can be set globally per function...
77
+ dtxt.configure(parse=MockBackend(), render=MockBackend())
78
+
79
+ # ...or overridden per call via backend=.
80
+ obj = dtxt.parse("Alice is 30 years old.", schema, backend=MockBackend())
81
+ text = dtxt.render({"name": "Alice", "age": 30}, schema)
82
+
83
+ result = dtxt.check_roundtrip({"name": "Alice", "age": 30}, schema)
84
+ result.ok # True if parse(render(obj)) == obj on every schema field
85
+ ```
86
+
87
+ Swap `MockBackend` for a real one:
88
+
89
+ ```python
90
+ dtxt.configure(
91
+ infer=dtxt.backends.Anthropic("claude-sonnet-4-6"),
92
+ parse=dtxt.backends.LlamaCpp("model.gguf", n_ctx=8192),
93
+ render=dtxt.backends.Anthropic("claude-sonnet-4-6"),
94
+ )
95
+ ```
96
+
97
+ `Anthropic` uses forced tool use to get structured output; `OpenAI` uses
98
+ `response_format={"type": "json_schema", ...}`; `LlamaCpp` constrains
99
+ decoding at the grammar level via GBNF. None of them guarantee full schema
100
+ conformance on their own:
101
+
102
+ - Anthropic/OpenAI guarantee valid JSON syntax, not every schema keyword.
103
+ - `LlamaCpp` strips constructs GBNF can't reliably express (`format`,
104
+ `pattern`, deeply nested objects/arrays) from the grammar-facing schema;
105
+ the original schema is still checked afterwards.
106
+
107
+ So all three go through dtxt's retry + validation loop the same way.
108
+ `parse_many` runs concurrently via asyncio for Anthropic/OpenAI, bounded by
109
+ `max_concurrency` (default 8) to avoid tripping rate limits; `LlamaCpp`
110
+ processes it sequentially in-process so its prompt cache stays warm. A
111
+ partial batch failure raises one `ParseError` naming how many texts failed
112
+ and the first failing index, rather than aborting on the first error.
113
+
114
+ Style is controllable at both the schema and call level:
115
+
116
+ ```python
117
+ schema = Schema({
118
+ "type": "object",
119
+ "properties": {"name": {"type": "string"}},
120
+ "required": ["name"],
121
+ "x-dtxt-style": "formal, third person", # schema-wide default
122
+ })
123
+ dtxt.render(obj, schema) # uses "formal, third person"
124
+ dtxt.render(obj, schema, style="casual, upbeat") # overrides it for this call
125
+ ```
126
+
127
+ ## Status
128
+
129
+ Early development (`0.0.x`). M1-M5 of the milestone plan are implemented:
130
+ `Schema`, `parse` / `parse_many` (asyncio-parallel + bounded concurrency
131
+ for API backends), `render` (with schema-level and per-call style
132
+ control), `infer_schema` (sampling + merge, `min_coverage`),
133
+ `check_roundtrip`, `configure`, a mock backend for testing, and the
134
+ Anthropic / OpenAI / llama.cpp backends. Not yet done: publishing to PyPI
135
+ as `0.1.0` -- see `CLAUDE.md` for the milestone plan.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ uv sync --dev
141
+ uv run pytest
142
+ uv run ruff check . && uv run ruff format --check .
143
+ uv run mypy src/
144
+ ```
@@ -0,0 +1,20 @@
1
+ dtxt/__init__.py,sha256=e84pUVjy851AnETN4ddW6TuyyDpFaZf5d31xupGy2Eg,548
2
+ dtxt/_config.py,sha256=GtbtiYmhKR_2_HriLPD_UkUuHTu3_xlka_Jrr1V8dmE,1413
3
+ dtxt/_util.py,sha256=iELcu6nCa8foaRHjvX8F43vTx8itGkvr8nbr4LqTp1c,516
4
+ dtxt/backends/__init__.py,sha256=8ygdU7X_qLFr7KwX4oUC0eAJyn1rzhono0UYupmfNAU,1002
5
+ dtxt/backends/anthropic.py,sha256=sRopjVAledL_rNq3_eFYHt2N_oXZV4BkAi3HMYyDp9Y,3963
6
+ dtxt/backends/base.py,sha256=eLP4emV_7vhd0Ud_pJW4gCpgqvxKUuuEIc3BYkfdcic,857
7
+ dtxt/backends/llamacpp.py,sha256=afIJ0j5jMdEbpm9QUK1yIek6Hl2i93h24hWfX6PJr20,4596
8
+ dtxt/backends/mock.py,sha256=d8sbCqqncN9nw9laXIfGXwENu-bywmsjkYaRzHSTPd4,1909
9
+ dtxt/backends/openai.py,sha256=9Y4gBx09ebQ_GyAaAnUBpcOTks1iGHzDAY5cpbR3nGw,3172
10
+ dtxt/d2t.py,sha256=1tnB4ENQRhPKg1P6sCNWmptevU2cs1CERbe664AeitI,1579
11
+ dtxt/infer.py,sha256=YaGxu6X_cKohGVN84Jvgz18UWWGSj5sY1Kpj0_MbrrM,3111
12
+ dtxt/prompts.py,sha256=n51sElLY2qYWwCd_QGkGyLt7izpQETZN7tIYsXtlFoU,2645
13
+ dtxt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ dtxt/roundtrip.py,sha256=mXxsDECWg-OgmVez5YdT4reaHsHXhuFzwdL7EimP2tM,1565
15
+ dtxt/schema.py,sha256=2xYF3op_EJNlALPeTiegN3_ZKrm5eBBjqrOhvvs-PoY,4063
16
+ dtxt/t2d.py,sha256=4xA3r-aYyuIqON0FTUZq0K7dd_U0uH5x6qrmRwo60HI,5625
17
+ dtxt-0.1.0.dist-info/licenses/LICENSE,sha256=CsKg5iZ5CavN1hLssUVZLR3KwXDysPkEzHHAE3-uwa0,1065
18
+ dtxt-0.1.0.dist-info/WHEEL,sha256=WvwXFgRajeoYkfRVmDhkP4Qlqo31Mk687zIO2QQoFmw,80
19
+ dtxt-0.1.0.dist-info/METADATA,sha256=-ATOj-yjV_rc1tRLPdYi1wIG5vIZOBaaJeddcWLVbWU,5087
20
+ dtxt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pillyshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.