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.
Files changed (131) hide show
  1. yaqpy/__init__.py +40 -0
  2. yaqpy/__main__.py +8 -0
  3. yaqpy/api.py +189 -0
  4. yaqpy/app/__init__.py +11 -0
  5. yaqpy/app/dto.py +58 -0
  6. yaqpy/app/examples.py +142 -0
  7. yaqpy/app/local.py +57 -0
  8. yaqpy/app/ports.py +75 -0
  9. yaqpy/app/printer.py +156 -0
  10. yaqpy/app/recipe_service.py +181 -0
  11. yaqpy/app/recipe_text.py +89 -0
  12. yaqpy/app/selfdoc.py +357 -0
  13. yaqpy/app/service.py +274 -0
  14. yaqpy/cli/__init__.py +5 -0
  15. yaqpy/cli/args.py +189 -0
  16. yaqpy/cli/describe_cli.py +40 -0
  17. yaqpy/cli/main.py +167 -0
  18. yaqpy/cli/parser.py +252 -0
  19. yaqpy/cli/recipe_cli.py +164 -0
  20. yaqpy/core/__init__.py +1 -0
  21. yaqpy/core/engine/__init__.py +7 -0
  22. yaqpy/core/engine/context.py +90 -0
  23. yaqpy/core/engine/helpers.py +149 -0
  24. yaqpy/core/engine/limits.py +33 -0
  25. yaqpy/core/engine/navigator.py +62 -0
  26. yaqpy/core/lang/__init__.py +11 -0
  27. yaqpy/core/lang/ast.py +92 -0
  28. yaqpy/core/lang/lex_rules.py +476 -0
  29. yaqpy/core/lang/lexer.py +116 -0
  30. yaqpy/core/lang/parser.py +83 -0
  31. yaqpy/core/lang/postfix.py +93 -0
  32. yaqpy/core/lang/prefs.py +85 -0
  33. yaqpy/core/lang/specs.py +143 -0
  34. yaqpy/core/lang/tokens.py +41 -0
  35. yaqpy/core/model/__init__.py +6 -0
  36. yaqpy/core/model/convert.py +85 -0
  37. yaqpy/core/model/datetime_util.py +573 -0
  38. yaqpy/core/model/depth.py +19 -0
  39. yaqpy/core/model/leading.py +38 -0
  40. yaqpy/core/model/node.py +444 -0
  41. yaqpy/core/model/tags.py +126 -0
  42. yaqpy/core/operators/__init__.py +32 -0
  43. yaqpy/core/operators/anchors.py +189 -0
  44. yaqpy/core/operators/arithmetic.py +307 -0
  45. yaqpy/core/operators/assign.py +52 -0
  46. yaqpy/core/operators/basic.py +124 -0
  47. yaqpy/core/operators/codecs.py +203 -0
  48. yaqpy/core/operators/collections.py +540 -0
  49. yaqpy/core/operators/datetime_ops.py +157 -0
  50. yaqpy/core/operators/documents.py +17 -0
  51. yaqpy/core/operators/logic.py +230 -0
  52. yaqpy/core/operators/meta.py +220 -0
  53. yaqpy/core/operators/multiply.py +140 -0
  54. yaqpy/core/operators/prune.py +55 -0
  55. yaqpy/core/operators/regex.py +220 -0
  56. yaqpy/core/operators/registry.py +91 -0
  57. yaqpy/core/operators/schema.py +206 -0
  58. yaqpy/core/operators/sequences.py +252 -0
  59. yaqpy/core/operators/slice.py +45 -0
  60. yaqpy/core/operators/strings.py +359 -0
  61. yaqpy/core/operators/structure.py +199 -0
  62. yaqpy/core/operators/traverse.py +205 -0
  63. yaqpy/errors.py +144 -0
  64. yaqpy/formats/__init__.py +5 -0
  65. yaqpy/formats/base.py +30 -0
  66. yaqpy/formats/csv_codec.py +294 -0
  67. yaqpy/formats/json_codec.py +207 -0
  68. yaqpy/formats/props_codec.py +402 -0
  69. yaqpy/formats/registry.py +205 -0
  70. yaqpy/formats/sniff.py +89 -0
  71. yaqpy/formats/toml_codec.py +804 -0
  72. yaqpy/formats/toon_codec.py +829 -0
  73. yaqpy/formats/xml_codec.py +601 -0
  74. yaqpy/formats/xml_tokens.py +480 -0
  75. yaqpy/formats/yaml/__init__.py +8 -0
  76. yaqpy/formats/yaml/codec.py +144 -0
  77. yaqpy/formats/yaml/emitter.py +404 -0
  78. yaqpy/formats/yaml/parser.py +1335 -0
  79. yaqpy/formats/yaml/resolver.py +31 -0
  80. yaqpy/gui/__init__.py +12 -0
  81. yaqpy/gui/_di.py +60 -0
  82. yaqpy/gui/_prefs.py +38 -0
  83. yaqpy/gui/_run.py +180 -0
  84. yaqpy/gui/_upload.py +145 -0
  85. yaqpy/gui/_web.py +126 -0
  86. yaqpy/gui/app.py +190 -0
  87. yaqpy/gui/assets/web/favicon.png +0 -0
  88. yaqpy/gui/assets/web/icons/loading-animation.png +0 -0
  89. yaqpy/gui/assets/yaqpy-logo.ico +0 -0
  90. yaqpy/gui/errors_ja.py +96 -0
  91. yaqpy/gui/intake.py +92 -0
  92. yaqpy/gui/logo.py +21 -0
  93. yaqpy/gui/pages/__init__.py +1 -0
  94. yaqpy/gui/pages/main_page.py +868 -0
  95. yaqpy/gui/pages/settings_page.py +156 -0
  96. yaqpy/gui/paths.py +111 -0
  97. yaqpy/gui/presenter.py +575 -0
  98. yaqpy/gui/state.py +217 -0
  99. yaqpy/gui/texts.py +300 -0
  100. yaqpy/gui/web_config.py +123 -0
  101. yaqpy/options.py +164 -0
  102. yaqpy/py.typed +0 -0
  103. yaqpy/recipes/__init__.py +17 -0
  104. yaqpy/recipes/analysis.py +83 -0
  105. yaqpy/recipes/builtin/anthropic-messages-request.schema.json +194 -0
  106. yaqpy/recipes/builtin/anthropic-to-gemini.recipe.yaml +229 -0
  107. yaqpy/recipes/builtin/anthropic-to-gemini.yaqpy +19 -0
  108. yaqpy/recipes/builtin/anthropic-to-openai.recipe.yaml +215 -0
  109. yaqpy/recipes/builtin/anthropic-to-openai.yaqpy +21 -0
  110. yaqpy/recipes/builtin/gemini-generate-content-request.schema.json +185 -0
  111. yaqpy/recipes/builtin/gemini-to-anthropic.recipe.yaml +243 -0
  112. yaqpy/recipes/builtin/gemini-to-anthropic.yaqpy +22 -0
  113. yaqpy/recipes/builtin/gemini-to-openai.recipe.yaml +239 -0
  114. yaqpy/recipes/builtin/gemini-to-openai.yaqpy +28 -0
  115. yaqpy/recipes/builtin/openai-chat-request.schema.json +404 -0
  116. yaqpy/recipes/builtin/openai-to-anthropic.recipe.yaml +247 -0
  117. yaqpy/recipes/builtin/openai-to-anthropic.yaqpy +25 -0
  118. yaqpy/recipes/builtin/openai-to-gemini.recipe.yaml +245 -0
  119. yaqpy/recipes/builtin/openai-to-gemini.yaqpy +36 -0
  120. yaqpy/recipes/catalog.py +63 -0
  121. yaqpy/recipes/conform.py +170 -0
  122. yaqpy/recipes/diff.py +108 -0
  123. yaqpy/recipes/loader.py +158 -0
  124. yaqpy/recipes/model.py +58 -0
  125. yaqpy/recipes/paths.py +156 -0
  126. yaqpy-0.6.0.dist-info/METADATA +165 -0
  127. yaqpy-0.6.0.dist-info/RECORD +131 -0
  128. yaqpy-0.6.0.dist-info/WHEEL +4 -0
  129. yaqpy-0.6.0.dist-info/entry_points.txt +5 -0
  130. yaqpy-0.6.0.dist-info/licenses/LICENSE +21 -0
  131. 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
@@ -0,0 +1,8 @@
1
+ """``python -m yaqpy`` entry point."""
2
+
3
+ import sys
4
+
5
+ from yaqpy.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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