yaqpy 0.6.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.
- yaqpy/__init__.py +40 -0
- yaqpy/__main__.py +8 -0
- yaqpy/api.py +189 -0
- yaqpy/app/__init__.py +11 -0
- yaqpy/app/dto.py +58 -0
- yaqpy/app/examples.py +142 -0
- yaqpy/app/local.py +57 -0
- yaqpy/app/ports.py +75 -0
- yaqpy/app/printer.py +156 -0
- yaqpy/app/recipe_service.py +181 -0
- yaqpy/app/recipe_text.py +89 -0
- yaqpy/app/selfdoc.py +357 -0
- yaqpy/app/service.py +274 -0
- yaqpy/cli/__init__.py +5 -0
- yaqpy/cli/args.py +189 -0
- yaqpy/cli/describe_cli.py +40 -0
- yaqpy/cli/main.py +167 -0
- yaqpy/cli/parser.py +252 -0
- yaqpy/cli/recipe_cli.py +164 -0
- yaqpy/core/__init__.py +1 -0
- yaqpy/core/engine/__init__.py +7 -0
- yaqpy/core/engine/context.py +90 -0
- yaqpy/core/engine/helpers.py +149 -0
- yaqpy/core/engine/limits.py +33 -0
- yaqpy/core/engine/navigator.py +62 -0
- yaqpy/core/lang/__init__.py +11 -0
- yaqpy/core/lang/ast.py +92 -0
- yaqpy/core/lang/lex_rules.py +476 -0
- yaqpy/core/lang/lexer.py +116 -0
- yaqpy/core/lang/parser.py +83 -0
- yaqpy/core/lang/postfix.py +93 -0
- yaqpy/core/lang/prefs.py +85 -0
- yaqpy/core/lang/specs.py +143 -0
- yaqpy/core/lang/tokens.py +41 -0
- yaqpy/core/model/__init__.py +6 -0
- yaqpy/core/model/convert.py +85 -0
- yaqpy/core/model/datetime_util.py +573 -0
- yaqpy/core/model/depth.py +19 -0
- yaqpy/core/model/leading.py +38 -0
- yaqpy/core/model/node.py +444 -0
- yaqpy/core/model/tags.py +126 -0
- yaqpy/core/operators/__init__.py +32 -0
- yaqpy/core/operators/anchors.py +189 -0
- yaqpy/core/operators/arithmetic.py +307 -0
- yaqpy/core/operators/assign.py +52 -0
- yaqpy/core/operators/basic.py +124 -0
- yaqpy/core/operators/codecs.py +203 -0
- yaqpy/core/operators/collections.py +540 -0
- yaqpy/core/operators/datetime_ops.py +157 -0
- yaqpy/core/operators/documents.py +17 -0
- yaqpy/core/operators/logic.py +230 -0
- yaqpy/core/operators/meta.py +220 -0
- yaqpy/core/operators/multiply.py +140 -0
- yaqpy/core/operators/prune.py +55 -0
- yaqpy/core/operators/regex.py +220 -0
- yaqpy/core/operators/registry.py +91 -0
- yaqpy/core/operators/schema.py +206 -0
- yaqpy/core/operators/sequences.py +252 -0
- yaqpy/core/operators/slice.py +45 -0
- yaqpy/core/operators/strings.py +359 -0
- yaqpy/core/operators/structure.py +199 -0
- yaqpy/core/operators/traverse.py +205 -0
- yaqpy/errors.py +144 -0
- yaqpy/formats/__init__.py +5 -0
- yaqpy/formats/base.py +30 -0
- yaqpy/formats/csv_codec.py +294 -0
- yaqpy/formats/json_codec.py +207 -0
- yaqpy/formats/props_codec.py +402 -0
- yaqpy/formats/registry.py +205 -0
- yaqpy/formats/sniff.py +89 -0
- yaqpy/formats/toml_codec.py +804 -0
- yaqpy/formats/toon_codec.py +829 -0
- yaqpy/formats/xml_codec.py +601 -0
- yaqpy/formats/xml_tokens.py +480 -0
- yaqpy/formats/yaml/__init__.py +8 -0
- yaqpy/formats/yaml/codec.py +144 -0
- yaqpy/formats/yaml/emitter.py +404 -0
- yaqpy/formats/yaml/parser.py +1335 -0
- yaqpy/formats/yaml/resolver.py +31 -0
- yaqpy/gui/__init__.py +12 -0
- yaqpy/gui/_di.py +60 -0
- yaqpy/gui/_prefs.py +38 -0
- yaqpy/gui/_run.py +180 -0
- yaqpy/gui/_upload.py +145 -0
- yaqpy/gui/_web.py +126 -0
- yaqpy/gui/app.py +190 -0
- yaqpy/gui/assets/web/favicon.png +0 -0
- yaqpy/gui/assets/web/icons/loading-animation.png +0 -0
- yaqpy/gui/assets/yaqpy-logo.ico +0 -0
- yaqpy/gui/errors_ja.py +96 -0
- yaqpy/gui/intake.py +92 -0
- yaqpy/gui/logo.py +21 -0
- yaqpy/gui/pages/__init__.py +1 -0
- yaqpy/gui/pages/main_page.py +868 -0
- yaqpy/gui/pages/settings_page.py +156 -0
- yaqpy/gui/paths.py +111 -0
- yaqpy/gui/presenter.py +575 -0
- yaqpy/gui/state.py +217 -0
- yaqpy/gui/texts.py +300 -0
- yaqpy/gui/web_config.py +123 -0
- yaqpy/options.py +164 -0
- yaqpy/py.typed +0 -0
- yaqpy/recipes/__init__.py +17 -0
- yaqpy/recipes/analysis.py +83 -0
- yaqpy/recipes/builtin/anthropic-messages-request.schema.json +194 -0
- yaqpy/recipes/builtin/anthropic-to-gemini.recipe.yaml +229 -0
- yaqpy/recipes/builtin/anthropic-to-gemini.yaqpy +19 -0
- yaqpy/recipes/builtin/anthropic-to-openai.recipe.yaml +215 -0
- yaqpy/recipes/builtin/anthropic-to-openai.yaqpy +21 -0
- yaqpy/recipes/builtin/gemini-generate-content-request.schema.json +185 -0
- yaqpy/recipes/builtin/gemini-to-anthropic.recipe.yaml +243 -0
- yaqpy/recipes/builtin/gemini-to-anthropic.yaqpy +22 -0
- yaqpy/recipes/builtin/gemini-to-openai.recipe.yaml +239 -0
- yaqpy/recipes/builtin/gemini-to-openai.yaqpy +28 -0
- yaqpy/recipes/builtin/openai-chat-request.schema.json +404 -0
- yaqpy/recipes/builtin/openai-to-anthropic.recipe.yaml +247 -0
- yaqpy/recipes/builtin/openai-to-anthropic.yaqpy +25 -0
- yaqpy/recipes/builtin/openai-to-gemini.recipe.yaml +245 -0
- yaqpy/recipes/builtin/openai-to-gemini.yaqpy +36 -0
- yaqpy/recipes/catalog.py +63 -0
- yaqpy/recipes/conform.py +170 -0
- yaqpy/recipes/diff.py +108 -0
- yaqpy/recipes/loader.py +158 -0
- yaqpy/recipes/model.py +58 -0
- yaqpy/recipes/paths.py +156 -0
- yaqpy-0.6.0.dist-info/METADATA +165 -0
- yaqpy-0.6.0.dist-info/RECORD +131 -0
- yaqpy-0.6.0.dist-info/WHEEL +4 -0
- yaqpy-0.6.0.dist-info/entry_points.txt +5 -0
- yaqpy-0.6.0.dist-info/licenses/LICENSE +21 -0
- yaqpy-0.6.0.dist-info/licenses/NOTICE +17 -0
yaqpy/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""yaqpy - a pure-Python (standard library only) implementation of mikefarah/yq.
|
|
2
|
+
|
|
3
|
+
>>> import yaqpy
|
|
4
|
+
>>> yaqpy.evaluate(".a.b", "a:\\n b: 3\\n")
|
|
5
|
+
'3\\n'
|
|
6
|
+
>>> yaqpy.query(".items[] | select(. > 1)", {"items": [1, 2, 3]})
|
|
7
|
+
[2, 3]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from yaqpy.api import (
|
|
11
|
+
Yq, apply_recipe, compile, detect_format, dump, evaluate, evaluate_all, list_recipes, load,
|
|
12
|
+
query, update,
|
|
13
|
+
)
|
|
14
|
+
from yaqpy.app.recipe_service import RecipeRun
|
|
15
|
+
from yaqpy.core.lang.parser import Expression
|
|
16
|
+
from yaqpy.core.model.node import Kind, Node, Style
|
|
17
|
+
from yaqpy.errors import (
|
|
18
|
+
EvaluationError, EvaluationLimitError, ExpressionSyntaxError, FormatError, RecipeError,
|
|
19
|
+
SecurityError, UnknownFormatError, YamlSyntaxError, YqError,
|
|
20
|
+
)
|
|
21
|
+
from yaqpy.options import (
|
|
22
|
+
CsvOptions, JsonOptions, Limits, Options, PropertiesOptions, SchemaOptions, SecurityPolicy,
|
|
23
|
+
TomlOptions, ToonOptions, XmlOptions, YamlOptions,
|
|
24
|
+
)
|
|
25
|
+
from yaqpy.recipes import Recipe, build_recipe
|
|
26
|
+
from yaqpy.recipes.analysis import RecipeReport
|
|
27
|
+
|
|
28
|
+
__version__ = "0.6.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Yq", "compile", "dump", "evaluate", "evaluate_all", "load", "query", "update",
|
|
32
|
+
"apply_recipe", "list_recipes", "build_recipe", "Recipe", "RecipeReport", "RecipeRun",
|
|
33
|
+
"detect_format",
|
|
34
|
+
"Expression", "Kind", "Node", "Style",
|
|
35
|
+
"EvaluationError", "EvaluationLimitError", "ExpressionSyntaxError", "FormatError", "RecipeError",
|
|
36
|
+
"SecurityError", "UnknownFormatError", "YamlSyntaxError", "YqError",
|
|
37
|
+
"CsvOptions", "JsonOptions", "Limits", "Options", "PropertiesOptions", "SchemaOptions",
|
|
38
|
+
"SecurityPolicy", "TomlOptions", "ToonOptions", "XmlOptions", "YamlOptions",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
yaqpy/__main__.py
ADDED
yaqpy/api.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Public library API (design doc section 10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from yaqpy.app.dto import EvalMode, EvaluateRequest, InputSource
|
|
10
|
+
from yaqpy.app.ports import SandboxFileSystem, StaticEnvironment
|
|
11
|
+
from yaqpy.app.printer import MemorySink
|
|
12
|
+
from yaqpy.app.recipe_service import RecipeRun, RecipeService
|
|
13
|
+
from yaqpy.app.service import YqService
|
|
14
|
+
from yaqpy.core.lang.parser import Expression
|
|
15
|
+
from yaqpy.core.model.convert import from_python, to_python
|
|
16
|
+
from yaqpy.core.model.node import Node
|
|
17
|
+
from yaqpy.core.operators import OperatorRegistry, builtin_registry
|
|
18
|
+
from yaqpy.formats.registry import FormatRegistry, builtin_formats
|
|
19
|
+
from yaqpy.options import Options
|
|
20
|
+
from yaqpy.recipes import Recipe, builtin_recipes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Yq:
|
|
24
|
+
"""Stateless facade. Safe to share between threads."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
options: Options | None = None,
|
|
29
|
+
*,
|
|
30
|
+
operators: OperatorRegistry | None = None,
|
|
31
|
+
formats: FormatRegistry | None = None,
|
|
32
|
+
environ: Mapping[str, str] | None = None,
|
|
33
|
+
clock: Callable[[], datetime] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.options = options or Options()
|
|
36
|
+
if environ is None:
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
environ = os.environ
|
|
40
|
+
self._service = YqService(
|
|
41
|
+
SandboxFileSystem(), StaticEnvironment(environ),
|
|
42
|
+
operators=operators or builtin_registry(), formats=formats or builtin_formats(),
|
|
43
|
+
clock=clock,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------ compile
|
|
47
|
+
|
|
48
|
+
def compile(self, expression: str) -> Expression:
|
|
49
|
+
return self._service.compile(expression)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ text in / text out
|
|
52
|
+
|
|
53
|
+
def _request(self, expression: str | Expression, texts: Iterable[str], mode: EvalMode,
|
|
54
|
+
options: Options | None) -> EvaluateRequest:
|
|
55
|
+
source = expression.source if isinstance(expression, Expression) else expression
|
|
56
|
+
inputs = tuple(InputSource("<text>", text) for text in texts)
|
|
57
|
+
return EvaluateRequest(expression=source, inputs=inputs, mode=mode,
|
|
58
|
+
options=options or self.options)
|
|
59
|
+
|
|
60
|
+
def evaluate(self, expression: str | Expression, text: str = "", *,
|
|
61
|
+
options: Options | None = None) -> str:
|
|
62
|
+
request = self._request(expression, [text] if text != "" else [], EvalMode.STREAM, options)
|
|
63
|
+
result = self._service.evaluate(request, MemorySink())
|
|
64
|
+
return result.output or ""
|
|
65
|
+
|
|
66
|
+
def evaluate_all(self, expression: str | Expression, texts: Iterable[str], *,
|
|
67
|
+
options: Options | None = None) -> str:
|
|
68
|
+
request = self._request(expression, texts, EvalMode.ALL, options)
|
|
69
|
+
result = self._service.evaluate(request, MemorySink())
|
|
70
|
+
return result.output or ""
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------ nodes / python objects
|
|
73
|
+
|
|
74
|
+
def evaluate_nodes(self, expression: str | Expression, documents: Sequence[Node], *,
|
|
75
|
+
options: Options | None = None) -> list[Node]:
|
|
76
|
+
return self._service.evaluate_nodes(expression, documents, options or self.options)
|
|
77
|
+
|
|
78
|
+
def iter_results(self, expression: str | Expression, text: str, *,
|
|
79
|
+
options: Options | None = None) -> Iterator[Node]:
|
|
80
|
+
options = options or self.options
|
|
81
|
+
decoder = self._service.formats.decoder_for(options.input_format, options)
|
|
82
|
+
for doc in decoder.decode_documents(text):
|
|
83
|
+
yield from self.evaluate_nodes(expression, [doc], options=options)
|
|
84
|
+
|
|
85
|
+
def query(self, expression: str | Expression, data: Any, *,
|
|
86
|
+
options: Options | None = None) -> list[Any]:
|
|
87
|
+
root = from_python(data)
|
|
88
|
+
root.evaluate_together = True
|
|
89
|
+
results = self.evaluate_nodes(expression, [root], options=options)
|
|
90
|
+
return [to_python(n) for n in results]
|
|
91
|
+
|
|
92
|
+
def update(self, expression: str | Expression, data: Any, *,
|
|
93
|
+
options: Options | None = None) -> Any:
|
|
94
|
+
root = from_python(data)
|
|
95
|
+
root.evaluate_together = True
|
|
96
|
+
self.evaluate_nodes(expression, [root], options=options)
|
|
97
|
+
return to_python(root)
|
|
98
|
+
|
|
99
|
+
def load(self, text: str, *, format: str = "yaml", options: Options | None = None) -> list[Node]:
|
|
100
|
+
options = options or self.options
|
|
101
|
+
decoder = self._service.formats.decoder_for(format, options)
|
|
102
|
+
return list(decoder.decode_documents(text))
|
|
103
|
+
|
|
104
|
+
def dump(self, documents: Iterable[Node], *, format: str = "yaml",
|
|
105
|
+
options: Options | None = None) -> str:
|
|
106
|
+
from yaqpy.app.printer import ResultPrinter
|
|
107
|
+
|
|
108
|
+
options = options or self.options
|
|
109
|
+
spec = self._service.formats.get(format)
|
|
110
|
+
unwrap = options.unwrap_scalar if options.unwrap_scalar is not None else spec.unwrap_scalar_default
|
|
111
|
+
encoder = self._service.formats.encoder_for(format, options, unwrap)
|
|
112
|
+
sink = MemorySink()
|
|
113
|
+
ResultPrinter(encoder, sink).print_results(list(documents))
|
|
114
|
+
return sink.finish() or ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ recipes (a yaqpy extension)
|
|
118
|
+
|
|
119
|
+
def apply_recipe(self, recipe: str | Recipe, text: str, *, input_format: str = "json",
|
|
120
|
+
output_format: str = "json", prune_null: bool = False, prune_empty: bool = False,
|
|
121
|
+
options: Options | None = None) -> RecipeRun:
|
|
122
|
+
"""Convert ``text`` with a recipe: a bundled one by name, or a ``Recipe`` you built.
|
|
123
|
+
|
|
124
|
+
Never reads files or environment variables, whatever the options say. The result holds the
|
|
125
|
+
converted text (``output``) and a ``report`` of what was dropped, added or does not fit the
|
|
126
|
+
target schema.
|
|
127
|
+
"""
|
|
128
|
+
service = RecipeService(self._service)
|
|
129
|
+
if isinstance(recipe, str):
|
|
130
|
+
recipe = service.load(recipe)
|
|
131
|
+
return service.run(recipe, InputSource("<text>", text), options or self.options,
|
|
132
|
+
input_format=service.input_format_for(recipe, "", input_format),
|
|
133
|
+
output_format=service.output_format_for(recipe, output_format),
|
|
134
|
+
prune_null=prune_null, prune_empty=prune_empty)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_DEFAULT = Yq()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compile(expression: str) -> Expression: # noqa: A001 - mirrors the design doc
|
|
141
|
+
return _DEFAULT.compile(expression)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def evaluate(expression: str, text: str = "", *, options: Options | None = None) -> str:
|
|
145
|
+
return _DEFAULT.evaluate(expression, text, options=options)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def evaluate_all(expression: str, texts: Iterable[str], *, options: Options | None = None) -> str:
|
|
149
|
+
return _DEFAULT.evaluate_all(expression, texts, options=options)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def query(expression: str, data: Any, *, options: Options | None = None) -> list[Any]:
|
|
153
|
+
return _DEFAULT.query(expression, data, options=options)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def update(expression: str, data: Any, *, options: Options | None = None) -> Any:
|
|
157
|
+
return _DEFAULT.update(expression, data, options=options)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load(text: str, *, format: str = "yaml", options: Options | None = None) -> list[Node]:
|
|
161
|
+
return _DEFAULT.load(text, format=format, options=options)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def dump(documents: Iterable[Node], *, format: str = "yaml", options: Options | None = None) -> str:
|
|
165
|
+
return _DEFAULT.dump(documents, format=format, options=options)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def detect_format(text: str) -> str:
|
|
169
|
+
"""Guess the format of ``text`` from its content alone (a yaqpy extension; Go yq has no such
|
|
170
|
+
thing - it only ever looks at a file's extension).
|
|
171
|
+
|
|
172
|
+
Meant for text with no filename to go by, or none whose extension names a format: the same
|
|
173
|
+
guess ``Options(input_format="auto")`` falls back to once a filename's extension gives no
|
|
174
|
+
answer. Returns a format name ``Options(input_format=...)`` accepts; "yaml" is the fallback
|
|
175
|
+
when nothing in the content looks confident enough (never an error).
|
|
176
|
+
"""
|
|
177
|
+
return builtin_formats().guess("", text).name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def list_recipes() -> dict[str, Recipe]:
|
|
181
|
+
"""The bundled recipes by name."""
|
|
182
|
+
return dict(builtin_recipes())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def apply_recipe(recipe: str | Recipe, text: str, *, input_format: str = "json",
|
|
186
|
+
output_format: str = "json", prune_null: bool = False, prune_empty: bool = False,
|
|
187
|
+
options: Options | None = None) -> RecipeRun:
|
|
188
|
+
return _DEFAULT.apply_recipe(recipe, text, input_format=input_format, output_format=output_format,
|
|
189
|
+
prune_null=prune_null, prune_empty=prune_empty, options=options)
|
yaqpy/app/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Application layer: use cases shared by CLI, GUI and API adapters."""
|
|
2
|
+
|
|
3
|
+
from yaqpy.app.dto import EvalMode, EvaluateRequest, EvaluateResult, InputSource
|
|
4
|
+
from yaqpy.app.printer import InPlaceSink, MemorySink, ResultPrinter, StreamSink
|
|
5
|
+
from yaqpy.app.service import PRETTY_PRINT_EXP, YqService
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EvalMode", "EvaluateRequest", "EvaluateResult", "InputSource",
|
|
9
|
+
"InPlaceSink", "MemorySink", "ResultPrinter", "StreamSink",
|
|
10
|
+
"PRETTY_PRINT_EXP", "YqService",
|
|
11
|
+
]
|
yaqpy/app/dto.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Request / result objects shared by every adapter (design doc 11-1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from yaqpy.options import Options
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EvalMode(enum.Enum):
|
|
12
|
+
STREAM = "stream"
|
|
13
|
+
ALL = "all"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class InputSource:
|
|
18
|
+
name: str # file name, "-" for stdin, "<text>" for in-memory input
|
|
19
|
+
text: str | None = None # None -> read through the FileSystemPort
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
23
|
+
class EvaluateRequest:
|
|
24
|
+
expression: str
|
|
25
|
+
inputs: tuple[InputSource, ...] = ()
|
|
26
|
+
mode: EvalMode = EvalMode.STREAM
|
|
27
|
+
options: Options = field(default_factory=Options)
|
|
28
|
+
in_place: bool = False
|
|
29
|
+
exit_status: bool = False
|
|
30
|
+
input_format: str | None = None # resolved format name (None -> from options)
|
|
31
|
+
output_format: str | None = None
|
|
32
|
+
unwrap_scalar: bool | None = None
|
|
33
|
+
split_expression: str = "" # -s: name a file per result with this expression
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class EvaluateResult:
|
|
38
|
+
output: str | None
|
|
39
|
+
printed_anything: bool
|
|
40
|
+
document_count: int
|
|
41
|
+
warnings: tuple[str, ...]
|
|
42
|
+
elapsed_seconds: float
|
|
43
|
+
input_format: str = "" # actually used (after "auto" was resolved)
|
|
44
|
+
output_format: str = "" # actually used
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class ExpressionInfo:
|
|
49
|
+
expression: str
|
|
50
|
+
valid: bool
|
|
51
|
+
message: str = ""
|
|
52
|
+
position: int = -1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class FormatsInfo:
|
|
57
|
+
input_formats: tuple[str, ...]
|
|
58
|
+
output_formats: tuple[str, ...]
|
yaqpy/app/examples.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Worked examples for ``--example``, ``--guide-prompt`` and ``--skill-md``.
|
|
2
|
+
|
|
3
|
+
Every example is *run* when it is shown: what is printed is the output of the real engine, never a
|
|
4
|
+
hand-typed answer. ``expected`` pins that output, and a test compares the two, so an example can
|
|
5
|
+
not go stale without the test noticing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from yaqpy.app.dto import EvalMode, EvaluateRequest, InputSource
|
|
13
|
+
from yaqpy.app.printer import MemorySink
|
|
14
|
+
from yaqpy.app.recipe_service import RecipeService
|
|
15
|
+
from yaqpy.app.recipe_text import summary_lines
|
|
16
|
+
from yaqpy.app.service import YqService, with_prune
|
|
17
|
+
from yaqpy.options import Options, SecurityPolicy
|
|
18
|
+
|
|
19
|
+
SHOP_YAML = """\
|
|
20
|
+
server:
|
|
21
|
+
host: localhost
|
|
22
|
+
port: 8080
|
|
23
|
+
items:
|
|
24
|
+
- {name: pen, price: 120, tags: [a, b]}
|
|
25
|
+
- {name: cap, price: 80, tags: []}
|
|
26
|
+
- {name: bag, price: 300, tags: [b]}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
OPENAI_REQUEST = """\
|
|
30
|
+
{
|
|
31
|
+
"model": "gpt-4o",
|
|
32
|
+
"messages": [
|
|
33
|
+
{"role": "system", "content": "You are helpful."},
|
|
34
|
+
{"role": "user", "content": "Hello"},
|
|
35
|
+
{"role": "assistant", "content": "Hi!"}
|
|
36
|
+
],
|
|
37
|
+
"temperature": 0.7,
|
|
38
|
+
"max_completion_tokens": 256,
|
|
39
|
+
"stop": "END",
|
|
40
|
+
"stream": true
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Example:
|
|
47
|
+
title: str
|
|
48
|
+
input: str
|
|
49
|
+
input_name: str # what the file is called in the command line shown
|
|
50
|
+
expected: str # the output the example must give (pinned by a test)
|
|
51
|
+
expression: str = ""
|
|
52
|
+
flags: str = "" # shown before the expression, e.g. "-o json"
|
|
53
|
+
input_format: str = "yaml"
|
|
54
|
+
output_format: str = "yaml"
|
|
55
|
+
eval_all: bool = False
|
|
56
|
+
prune_null: bool = False
|
|
57
|
+
prune_empty: bool = False
|
|
58
|
+
recipe: str = "" # run this bundled recipe instead of an expression
|
|
59
|
+
expected_notes: tuple[str, ...] = ()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def command(self) -> str:
|
|
63
|
+
if self.recipe:
|
|
64
|
+
return " ".join(["yaqpy", "--recipe", self.recipe] + ([self.flags] if self.flags else [])
|
|
65
|
+
+ [self.input_name])
|
|
66
|
+
parts = ["yaqpy"]
|
|
67
|
+
if self.eval_all:
|
|
68
|
+
parts.append("eval-all")
|
|
69
|
+
if self.flags:
|
|
70
|
+
parts.append(self.flags)
|
|
71
|
+
if self.prune_null:
|
|
72
|
+
parts.append("--prune-null")
|
|
73
|
+
if self.prune_empty:
|
|
74
|
+
parts.append("--prune-empty")
|
|
75
|
+
if self.expression:
|
|
76
|
+
parts.append("'" + self.expression + "'")
|
|
77
|
+
parts.append(self.input_name)
|
|
78
|
+
return " ".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
EXAMPLES: tuple[Example, ...] = (
|
|
82
|
+
Example("値を取り出す", SHOP_YAML, "shop.yaml", "8080\n", ".server.port"),
|
|
83
|
+
Example("条件で絞り込む", SHOP_YAML, "shop.yaml", "pen\nbag\n",
|
|
84
|
+
".items[] | select(.price > 100) | .name"),
|
|
85
|
+
Example("値を更新する(更新したファイルにしたいときは -i を付ける)", SHOP_YAML, "shop.yaml",
|
|
86
|
+
"host: localhost\nport: 9090\n", ".server.port = 9090 | .server"),
|
|
87
|
+
Example("JSON に変換して整える", SHOP_YAML, "shop.yaml", '["pen","cap","bag"]\n',
|
|
88
|
+
".items | map(.name)", flags="-o json -I 0", output_format="json"),
|
|
89
|
+
Example("合計を求める(jq の reduce は ireduce)", SHOP_YAML, "shop.yaml", "500\n",
|
|
90
|
+
".items | .[] as $i ireduce (0; . + $i.price)"),
|
|
91
|
+
Example("並べ替える", SHOP_YAML, "shop.yaml", "bag\n",
|
|
92
|
+
".items | sort_by(.price) | reverse | .[0].name"),
|
|
93
|
+
Example("文字列をつなぐ", SHOP_YAML, "shop.yaml", "pen, cap, bag\n",
|
|
94
|
+
'[.items[].name] | join(", ")'),
|
|
95
|
+
Example("条件に合う要素だけを更新する(要素の中の値で書き換えるときは |=)", SHOP_YAML, "shop.yaml",
|
|
96
|
+
"[120,100,300]\n", "(.items[] | select(.price < 100)) |= (.price = 100) | .items | map(.price)",
|
|
97
|
+
flags="-o json -I 0", output_format="json"),
|
|
98
|
+
Example("表(CSV)に変える", SHOP_YAML, "shop.yaml", "name,price\npen,120\ncap,80\nbag,300\n",
|
|
99
|
+
'.items | map(pick(["name", "price"]))', flags="-o csv", output_format="csv"),
|
|
100
|
+
Example("キーがあるときだけ代入する(select は代入の左辺の先頭に置く)", SHOP_YAML, "shop.yaml",
|
|
101
|
+
"port: 8080\n", "(select(.server.port != null) | .backup.port) = .server.port | .backup"),
|
|
102
|
+
Example("2 つの文書を 1 つに重ねる", "a: 1\nb: 1\n---\nb: 2\nc: 3\n", "two.yaml",
|
|
103
|
+
"a: 1\nb: 2\nc: 3\n", ". as $doc ireduce ({}; . * $doc)", eval_all=True),
|
|
104
|
+
Example("データの形(JSON Schema)を出す — yaqpy 独自", SHOP_YAML, "shop.yaml",
|
|
105
|
+
'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object",'
|
|
106
|
+
'"properties":{"server":{"type":"object","properties":{"host":{"type":"string"},'
|
|
107
|
+
'"port":{"type":"integer"}},"required":["host","port"]},"items":{"type":"array",'
|
|
108
|
+
'"items":{"type":"object","properties":{"name":{"type":"string"},"price":{"type":"integer"},'
|
|
109
|
+
'"tags":{"type":"array","items":{"type":"string"}}},"required":["name","price","tags"]}}},'
|
|
110
|
+
'"required":["server","items"]}\n',
|
|
111
|
+
"schema", flags="-o json -I 0", output_format="json"),
|
|
112
|
+
Example("null と空の入れ物を取り除く — yaqpy 独自", '{"a": null, "b": {"c": null}, "d": 1}\n',
|
|
113
|
+
"in.json", '{"d":1}\n', "", flags="-o json -I 0", input_format="json",
|
|
114
|
+
output_format="json", prune_null=True, prune_empty=True),
|
|
115
|
+
Example("API のリクエストを変換する(レシピ)— yaqpy 独自", OPENAI_REQUEST, "request.json",
|
|
116
|
+
'{"systemInstruction":{"parts":[{"text":"You are helpful."}]},'
|
|
117
|
+
'"contents":[{"role":"user","parts":[{"text":"Hello"}]},{"role":"model","parts":[{"text":"Hi!"}]}],'
|
|
118
|
+
'"generationConfig":{"temperature":0.7,"maxOutputTokens":256,"stopSequences":["END"]}}\n',
|
|
119
|
+
input_format="json", output_format="json", recipe="openai-to-gemini", flags="-I 0",
|
|
120
|
+
expected_notes=("dropped .model", "dropped .stream")),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_example(service: YqService, example: Example) -> tuple[str, list[str]]:
|
|
125
|
+
"""Run an example on the real engine. Returns the output and, for a recipe, the notes it printed."""
|
|
126
|
+
options = Options(input_format=example.input_format, output_format=example.output_format,
|
|
127
|
+
indent=0 if "-I 0" in example.flags else 2, security=SecurityPolicy.strict())
|
|
128
|
+
if example.recipe:
|
|
129
|
+
recipes = RecipeService(service)
|
|
130
|
+
recipe = recipes.load(example.recipe)
|
|
131
|
+
run = recipes.run(recipe, InputSource("<text>", example.input), options,
|
|
132
|
+
input_format="json", output_format="json")
|
|
133
|
+
return run.output, summary_lines(run)
|
|
134
|
+
expression = with_prune(example.expression or ".", nulls=example.prune_null,
|
|
135
|
+
empties=example.prune_empty) if (example.prune_null or example.prune_empty) \
|
|
136
|
+
else example.expression
|
|
137
|
+
request = EvaluateRequest(
|
|
138
|
+
expression=expression, inputs=(InputSource("<text>", example.input),),
|
|
139
|
+
mode=EvalMode.ALL if example.eval_all else EvalMode.STREAM, options=options,
|
|
140
|
+
input_format=example.input_format, output_format=example.output_format)
|
|
141
|
+
result = service.evaluate(request, MemorySink())
|
|
142
|
+
return result.output or "", []
|
yaqpy/app/local.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Local implementations of the ports (pathlib / os.environ)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalFileSystem:
|
|
14
|
+
def read_text(self, path: str) -> str:
|
|
15
|
+
data = Path(path).read_bytes()
|
|
16
|
+
return data.decode("utf-8-sig")
|
|
17
|
+
|
|
18
|
+
def read_stdin(self) -> str:
|
|
19
|
+
data = sys.stdin.buffer.read()
|
|
20
|
+
return data.decode("utf-8-sig")
|
|
21
|
+
|
|
22
|
+
def atomic_write(self, path: str, text: str) -> None:
|
|
23
|
+
target = Path(path)
|
|
24
|
+
directory = target.parent if str(target.parent) else Path(".")
|
|
25
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".yaqpy-", suffix=".tmp", dir=str(directory))
|
|
26
|
+
tmp = Path(tmp_name)
|
|
27
|
+
try:
|
|
28
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
29
|
+
handle.write(text)
|
|
30
|
+
if target.exists():
|
|
31
|
+
try:
|
|
32
|
+
shutil.copymode(str(target), str(tmp))
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
os.replace(str(tmp), str(target))
|
|
36
|
+
except BaseException:
|
|
37
|
+
try:
|
|
38
|
+
tmp.unlink()
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
def write_file(self, path: str, text: str) -> None:
|
|
44
|
+
"""Create (or replace) a file, making its directories first (Go's ``MkdirAll`` + ``Create``)."""
|
|
45
|
+
parent = Path(path).parent
|
|
46
|
+
if str(parent) not in ("", "."):
|
|
47
|
+
parent.mkdir(mode=0o750, parents=True, exist_ok=True)
|
|
48
|
+
self.atomic_write(path, text)
|
|
49
|
+
|
|
50
|
+
def exists_file(self, path: str) -> bool:
|
|
51
|
+
p = Path(path)
|
|
52
|
+
return p.exists() and not p.is_dir()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LocalEnvironment:
|
|
56
|
+
def environ(self) -> Mapping[str, str]:
|
|
57
|
+
return os.environ
|
yaqpy/app/ports.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Ports: the abstract I/O the application layer needs (design doc 11-2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from yaqpy.errors import SecurityError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileSystemPort(Protocol):
|
|
12
|
+
def read_text(self, path: str) -> str: ...
|
|
13
|
+
def read_stdin(self) -> str: ...
|
|
14
|
+
def atomic_write(self, path: str, text: str) -> None: ...
|
|
15
|
+
def write_file(self, path: str, text: str) -> None: ...
|
|
16
|
+
def exists_file(self, path: str) -> bool: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnvironmentPort(Protocol):
|
|
20
|
+
def environ(self) -> Mapping[str, str]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxFileSystem:
|
|
24
|
+
"""Refuses every file access (used by the API service)."""
|
|
25
|
+
|
|
26
|
+
def read_text(self, path: str) -> str:
|
|
27
|
+
raise SecurityError(f"file access is not allowed: {path}", capability="file")
|
|
28
|
+
|
|
29
|
+
def read_stdin(self) -> str:
|
|
30
|
+
raise SecurityError("stdin is not available", capability="file")
|
|
31
|
+
|
|
32
|
+
def atomic_write(self, path: str, text: str) -> None:
|
|
33
|
+
raise SecurityError(f"file access is not allowed: {path}", capability="file")
|
|
34
|
+
|
|
35
|
+
def write_file(self, path: str, text: str) -> None:
|
|
36
|
+
raise SecurityError(f"file access is not allowed: {path}", capability="file")
|
|
37
|
+
|
|
38
|
+
def exists_file(self, path: str) -> bool:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InMemoryFileSystem:
|
|
43
|
+
"""Test double."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, files: dict[str, str] | None = None, stdin: str = "") -> None:
|
|
46
|
+
self.files = dict(files or {})
|
|
47
|
+
self.stdin = stdin
|
|
48
|
+
self.written: dict[str, str] = {}
|
|
49
|
+
|
|
50
|
+
def read_text(self, path: str) -> str:
|
|
51
|
+
try:
|
|
52
|
+
return self.files[path]
|
|
53
|
+
except KeyError:
|
|
54
|
+
raise FileNotFoundError(path) from None
|
|
55
|
+
|
|
56
|
+
def read_stdin(self) -> str:
|
|
57
|
+
return self.stdin
|
|
58
|
+
|
|
59
|
+
def atomic_write(self, path: str, text: str) -> None:
|
|
60
|
+
self.files[path] = text
|
|
61
|
+
self.written[path] = text
|
|
62
|
+
|
|
63
|
+
def write_file(self, path: str, text: str) -> None:
|
|
64
|
+
self.atomic_write(path, text)
|
|
65
|
+
|
|
66
|
+
def exists_file(self, path: str) -> bool:
|
|
67
|
+
return path in self.files
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StaticEnvironment:
|
|
71
|
+
def __init__(self, values: Mapping[str, str] | None = None) -> None:
|
|
72
|
+
self._values = dict(values or {})
|
|
73
|
+
|
|
74
|
+
def environ(self) -> Mapping[str, str]:
|
|
75
|
+
return self._values
|