okstra 0.30.3 → 0.32.0

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 (69) hide show
  1. package/docs/kr/architecture.md +2 -2
  2. package/docs/kr/cli.md +2 -2
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/SKILL.md +7 -5
  6. package/runtime/agents/workers/claude-worker.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +23 -6
  8. package/runtime/agents/workers/gemini-worker.md +23 -6
  9. package/runtime/agents/workers/report-writer-worker.md +45 -66
  10. package/runtime/bin/okstra-codex-exec.sh +31 -0
  11. package/runtime/bin/okstra-gemini-exec.sh +26 -0
  12. package/runtime/bin/okstra-render-final-report.py +101 -0
  13. package/runtime/bin/okstra-render-report-views.py +17 -10
  14. package/runtime/bin/okstra-token-usage.py +3 -1
  15. package/runtime/python/lib/okstra/globals.sh +1 -1
  16. package/runtime/python/lib/okstra/usage.sh +2 -2
  17. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  18. package/runtime/python/okstra_ctl/models.py +2 -0
  19. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  20. package/runtime/python/okstra_ctl/report_views.py +276 -297
  21. package/runtime/python/okstra_ctl/run.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +53 -14
  23. package/runtime/python/okstra_ctl/workers.py +45 -11
  24. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  25. package/runtime/python/okstra_token_usage/cli.py +66 -36
  26. package/runtime/python/okstra_token_usage/pricing.py +1 -0
  27. package/runtime/python/okstra_token_usage/report.py +148 -65
  28. package/runtime/python/okstra_vendor/__init__.py +37 -0
  29. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  30. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  31. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  32. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  33. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  34. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  35. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  36. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  37. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  38. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  39. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  40. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  41. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  42. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  43. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  44. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  45. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  46. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  47. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  48. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  49. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  50. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  51. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  52. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  53. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  54. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  55. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  56. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  57. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  58. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  59. package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
  60. package/runtime/skills/okstra-run/SKILL.md +6 -4
  61. package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
  62. package/runtime/templates/reports/final-report.template.md +370 -405
  63. package/runtime/templates/reports/report.css +57 -4
  64. package/runtime/templates/reports/report.js +63 -7
  65. package/runtime/templates/reports/settings.template.json +1 -0
  66. package/runtime/validators/lib/fixtures.sh +7 -7
  67. package/runtime/validators/validate-report-views.py +24 -153
  68. package/runtime/validators/validate-run.py +102 -19
  69. package/src/install.mjs +21 -1
@@ -1,93 +1,176 @@
1
- """Final-report substitution helpers (writes Phase 7 placeholders into final-report.md)."""
1
+ """Final-report data.json mutation helpers (Phase 7 token cells)."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
5
+ import sys
4
6
  from pathlib import Path
7
+ from typing import Any
5
8
 
9
+ # Make sibling packages importable when this module runs as part of an
10
+ # in-repo invocation. The installed runtime puts everything under
11
+ # ~/.okstra/lib/python which is already on PYTHONPATH via the bin
12
+ # wrapper, so this path tweak is a no-op there.
13
+ _HERE = Path(__file__).resolve().parent.parent
14
+ if str(_HERE) not in sys.path:
15
+ sys.path.insert(0, str(_HERE))
6
16
 
7
- def _format_int(n) -> str:
8
- try:
9
- return f"{int(n):,}"
10
- except (TypeError, ValueError):
11
- return "0"
12
-
13
-
14
- def _format_usd(v) -> str:
15
- try:
16
- return f"${float(v):.2f}"
17
- except (TypeError, ValueError):
18
- return "$0.00"
17
+ from okstra_ctl.render_final_report import ( # noqa: E402
18
+ RenderError,
19
+ render_to_file,
20
+ )
19
21
 
20
22
 
21
23
  class SubstituteRefusedError(RuntimeError):
22
- """Raised when substitution would write a zero-only Token Usage Summary.
24
+ """Raised when substitution would write zero-only token cells.
23
25
 
24
26
  Shipping `0` / `$0.00` in the Lead / Worker / Grand rows is the
25
27
  observed silent-failure mode where the collector ran but every
26
- session jsonl was empty (or the writer fabricated zeros). The
27
- validator catches it post-hoc, but raising here at the substitution
28
- boundary surfaces the failure at the exact step where it can still
29
- be retried with a re-collection.
28
+ session jsonl was empty (or the writer fabricated zeros). Surfacing
29
+ the refusal here lets the caller retry after locating the missing
30
+ session jsonls instead of baking zeros into the report.
30
31
  """
31
32
 
32
33
 
33
- def substitute_final_report(report_path: Path, state: dict) -> int:
34
- """Replace token-usage placeholders in the final report file with concrete
35
- values from the freshly computed usageSummary.
34
+ def _role_match(state_role: str, data_role: str) -> bool:
35
+ """Loose match between team-state worker role and data.json
36
+ executionStatus row role. team-state uses tags like ``claude-worker``
37
+ or ``report-writer``; data.json uses human strings like ``Claude
38
+ worker``. We normalise both to ``alnum-only-lowercase`` so the two
39
+ line up across rephrasings.
40
+ """
41
+ def normalise(s: str) -> str:
42
+ return "".join(ch.lower() for ch in s if ch.isalnum())
43
+ return normalise(state_role) == normalise(data_role)
36
44
 
37
- Returns the number of placeholder occurrences replaced. If the report file
38
- does not exist, returns -1 without raising.
39
45
 
40
- Raises ``SubstituteRefusedError`` when ``usageSummary.grandTotalTokens``
41
- is zero substituting zeros into the report bakes in the most common
42
- silent failure mode (collector ran but found nothing). Callers that
43
- want to suppress the refusal (e.g. unit-test fixtures) can pass a
44
- summary with ``grandTotalTokens`` > 0 or remove the summary entirely
45
- so substitution is skipped.
46
+ def _populate_execution_row(row: dict, source: dict) -> None:
47
+ """Fill the executionStatus row's token / cost / duration cells from a
48
+ team-state worker (or lead) entry. ``source`` is the dict that owns
49
+ a ``usage`` sub-dict and (for workers) a ``status`` field.
46
50
  """
47
- if not report_path.is_file():
48
- return -1
51
+ usage = source.get("usage") or {}
52
+ if usage.get("source") == "unavailable":
53
+ # Leave cells null; renderer emits `--`. A note in the row's
54
+ # summary is the worker's responsibility, not ours.
55
+ return
56
+ if "totalTokens" in usage:
57
+ row["totalTokens"] = usage["totalTokens"]
58
+ if "billableEquivalentTokens" in usage:
59
+ row["billableTokens"] = usage["billableEquivalentTokens"]
60
+ if "estimatedCostUsd" in usage:
61
+ row["costUsd"] = usage["estimatedCostUsd"]
62
+ if "durationMs" in usage:
63
+ row["durationMs"] = usage["durationMs"]
64
+ if "cliTotalTokens" in usage:
65
+ row["cliTotalTokens"] = usage["cliTotalTokens"]
66
+ if "cliEstimatedCostUsd" in usage:
67
+ row["cliCostUsd"] = usage["cliEstimatedCostUsd"]
68
+
69
+
70
+ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
71
+ """Mutate ``data_path`` (final-report data.json) in place: fill
72
+ ``tokenUsage`` rows and ``executionStatus`` row token cells from
73
+ ``team_state['usageSummary']`` and ``team_state['workers']`` /
74
+ ``team_state['lead']``. Returns the number of cells changed (rough
75
+ count for diagnostics).
49
76
 
50
- summary = state.get("usageSummary") or {}
77
+ Raises ``SubstituteRefusedError`` when ``usageSummary.grandTotalTokens``
78
+ is zero — substituting zeros bakes in the "collector ran but found
79
+ nothing" failure mode. Callers that intentionally want zeros (e.g.
80
+ unit-test fixtures) must omit ``usageSummary`` from the team-state.
81
+ """
82
+ summary = team_state.get("usageSummary") or {}
51
83
  grand_total = summary.get("grandTotalTokens", 0)
52
84
  if isinstance(grand_total, (int, float)) and grand_total == 0 and summary:
53
85
  raise SubstituteRefusedError(
54
- "Refusing to substitute zero-only usageSummary into the final "
55
- f"report at {report_path}. grandTotalTokens=0 means the "
56
- "collector ran but every session jsonl was empty (or absent). "
57
- "Re-run `python3 scripts/okstra-token-usage.py <team-state> "
58
- "--write --summary --substitute-final-report <report-path>` "
86
+ "Refusing to substitute zero-only usageSummary into "
87
+ f"{data_path}. grandTotalTokens=0 means the collector ran "
88
+ "but every session jsonl was empty (or absent). Re-run "
59
89
  "after locating the missing session jsonls. To intentionally "
60
- "ship zeros (test fixtures only), omit `usageSummary` from the "
61
- "team-state JSON before calling substitute_final_report."
90
+ "ship zeros (test fixtures only), omit `usageSummary` from "
91
+ "team-state before calling this."
62
92
  )
93
+
94
+ if not data_path.is_file():
95
+ raise SubstituteRefusedError(f"data file not found: {data_path}")
96
+
97
+ data = json.loads(data_path.read_text(encoding="utf-8"))
98
+ changes = 0
99
+
100
+ # Token Usage Summary table — four rows.
63
101
  cost = summary.get("estimatedCostUsd") or {}
64
102
  lead_cost = cost.get("lead") or 0
65
103
  worker_cost = cost.get("claudeWorkers") or 0
66
104
  cli_cost = cost.get("cliWorkers") or 0
67
- grand_cost = lead_cost + worker_cost # CLI tracked on its own row per template
68
-
69
- mapping = {
70
- "{{LEAD_TOTAL_TOKENS}}": _format_int(summary.get("leadTotalTokens", 0)),
71
- "{{LEAD_BILLABLE_TOKENS}}": _format_int(summary.get("leadBillableEquivalentTokens", 0)),
72
- "{{LEAD_COST_USD}}": _format_usd(lead_cost),
73
- "{{WORKER_TOTAL_TOKENS}}": _format_int(summary.get("workerTotalTokens", 0)),
74
- "{{WORKER_BILLABLE_TOKENS}}": _format_int(summary.get("workerBillableEquivalentTokens", 0)),
75
- "{{WORKER_COST_USD}}": _format_usd(worker_cost),
76
- "{{GRAND_TOTAL_TOKENS}}": _format_int(summary.get("grandTotalTokens", 0)),
77
- "{{GRAND_BILLABLE_TOKENS}}": _format_int(summary.get("grandBillableEquivalentTokens", 0)),
78
- "{{GRAND_COST_USD}}": _format_usd(grand_cost),
79
- "{{CLI_COST_USD}}": _format_usd(cli_cost),
80
- }
81
-
82
- text = report_path.read_text()
83
- replaced = 0
84
- for needle, value in mapping.items():
85
- count = text.count(needle)
86
- if count:
87
- text = text.replace(needle, value)
88
- replaced += count
89
-
90
- if replaced:
91
- report_path.write_text(text)
92
- return replaced
93
105
 
106
+ token_usage = data.setdefault("tokenUsage", {})
107
+ token_usage.setdefault("lead", {}).update({
108
+ "totalTokens": summary.get("leadTotalTokens"),
109
+ "billableTokens": summary.get("leadBillableEquivalentTokens"),
110
+ "costUsd": lead_cost,
111
+ })
112
+ token_usage.setdefault("worker", {}).update({
113
+ "totalTokens": summary.get("workerTotalTokens"),
114
+ "billableTokens": summary.get("workerBillableEquivalentTokens"),
115
+ "costUsd": worker_cost,
116
+ })
117
+ token_usage.setdefault("grand", {}).update({
118
+ "totalTokens": summary.get("grandTotalTokens"),
119
+ "billableTokens": summary.get("grandBillableEquivalentTokens"),
120
+ # CLI tracked on its own row per the template — grand here means
121
+ # lead + claudeWorkers, not lead + claudeWorkers + cli.
122
+ "costUsd": lead_cost + worker_cost,
123
+ })
124
+ token_usage.setdefault("cli", {})["costUsd"] = cli_cost
125
+ changes += 4
126
+
127
+ # Execution Status by Agent — per-row token / cost / duration.
128
+ lead_entry = team_state.get("lead") or {}
129
+ workers = team_state.get("workers") or []
130
+ rows = data.get("executionStatus") or []
131
+ for row in rows:
132
+ role = row.get("role", "")
133
+ if "lead" in role.lower():
134
+ _populate_execution_row(row, lead_entry)
135
+ changes += 1
136
+ continue
137
+ for worker in workers:
138
+ if _role_match(worker.get("role", ""), role):
139
+ _populate_execution_row(row, worker)
140
+ changes += 1
141
+ break
142
+
143
+ data_path.write_text(
144
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
145
+ encoding="utf-8",
146
+ )
147
+ return changes
148
+
149
+
150
+ def populate_and_render(
151
+ data_path: Path,
152
+ team_state: dict,
153
+ *,
154
+ output_path: Path | None = None,
155
+ ) -> tuple[int, int]:
156
+ """Mutate data.json + render the markdown sibling. Returns
157
+ ``(cells_changed, bytes_written)``.
158
+
159
+ The caller is normally Phase 7 step 1 (token-usage CLI with the
160
+ ``--substitute-data`` flag). The rendered markdown is the canonical
161
+ user-facing artifact; data.json is the SSOT.
162
+ """
163
+ changes = populate_data_token_cells(data_path, team_state)
164
+
165
+ if output_path is None:
166
+ name = data_path.name
167
+ if name.endswith(".data.json"):
168
+ output_path = data_path.with_name(name[: -len(".data.json")] + ".md")
169
+ else:
170
+ output_path = data_path.with_suffix(".md")
171
+
172
+ try:
173
+ bytes_written = render_to_file(data_path, output_path)
174
+ except RenderError as exc:
175
+ raise SubstituteRefusedError(f"render after substitute failed: {exc}") from exc
176
+ return changes, bytes_written
@@ -0,0 +1,37 @@
1
+ """Vendored third-party dependencies used by okstra runtime scripts.
2
+
3
+ This package isolates third-party Python libraries that okstra needs at
4
+ runtime so that end users do not need to `pip install` anything separate
5
+ from the npm-distributed runtime. The build system (`tools/build.mjs`)
6
+ copies this package into `runtime/python/okstra_vendor/` and the
7
+ installer (`src/install.mjs`) drops it under `~/.okstra/lib/python/`.
8
+
9
+ Currently vendored:
10
+ - jinja2 3.1.6 (BSD-3-Clause) — final-report template rendering.
11
+ - markupsafe 3.0.3 (BSD-3-Clause) — jinja2 dependency. C extension
12
+ (`_speedups.c` / `_speedups.pyi`) is stripped; the pure-python
13
+ `_native` fallback is used at import time.
14
+
15
+ Source provenance and licenses are preserved verbatim under each
16
+ sub-package. Do not edit vendored files in place — re-vendor from PyPI
17
+ sdists when upgrading.
18
+
19
+ Import alias rationale: jinja2 uses absolute imports (`from markupsafe
20
+ import ...`, and compiled templates `import jinja2`) internally. Without
21
+ re-routing, those would either fail (no system install) or resolve to
22
+ unrelated user-installed copies. We register the vendored copies in
23
+ `sys.modules` under their canonical top-level names so jinja2's internal
24
+ imports find them deterministically.
25
+ """
26
+
27
+ import sys as _sys
28
+
29
+ # Register markupsafe alias BEFORE importing jinja2 — jinja2's __init__
30
+ # evaluates `from .environment import ...`, which does `from markupsafe
31
+ # import Markup` at module-load time. Reversing the order would crash
32
+ # the import chain before the alias was registered.
33
+ from . import markupsafe as _markupsafe
34
+ _sys.modules.setdefault("markupsafe", _markupsafe)
35
+
36
+ from . import jinja2 as _jinja2 # noqa: E402
37
+ _sys.modules.setdefault("jinja2", _jinja2)
@@ -0,0 +1,38 @@
1
+ """Jinja is a template engine written in pure Python. It provides a
2
+ non-XML syntax that supports inline expressions and an optional
3
+ sandboxed environment.
4
+ """
5
+
6
+ from .bccache import BytecodeCache as BytecodeCache
7
+ from .bccache import FileSystemBytecodeCache as FileSystemBytecodeCache
8
+ from .bccache import MemcachedBytecodeCache as MemcachedBytecodeCache
9
+ from .environment import Environment as Environment
10
+ from .environment import Template as Template
11
+ from .exceptions import TemplateAssertionError as TemplateAssertionError
12
+ from .exceptions import TemplateError as TemplateError
13
+ from .exceptions import TemplateNotFound as TemplateNotFound
14
+ from .exceptions import TemplateRuntimeError as TemplateRuntimeError
15
+ from .exceptions import TemplatesNotFound as TemplatesNotFound
16
+ from .exceptions import TemplateSyntaxError as TemplateSyntaxError
17
+ from .exceptions import UndefinedError as UndefinedError
18
+ from .loaders import BaseLoader as BaseLoader
19
+ from .loaders import ChoiceLoader as ChoiceLoader
20
+ from .loaders import DictLoader as DictLoader
21
+ from .loaders import FileSystemLoader as FileSystemLoader
22
+ from .loaders import FunctionLoader as FunctionLoader
23
+ from .loaders import ModuleLoader as ModuleLoader
24
+ from .loaders import PackageLoader as PackageLoader
25
+ from .loaders import PrefixLoader as PrefixLoader
26
+ from .runtime import ChainableUndefined as ChainableUndefined
27
+ from .runtime import DebugUndefined as DebugUndefined
28
+ from .runtime import make_logging_undefined as make_logging_undefined
29
+ from .runtime import StrictUndefined as StrictUndefined
30
+ from .runtime import Undefined as Undefined
31
+ from .utils import clear_caches as clear_caches
32
+ from .utils import is_undefined as is_undefined
33
+ from .utils import pass_context as pass_context
34
+ from .utils import pass_environment as pass_environment
35
+ from .utils import pass_eval_context as pass_eval_context
36
+ from .utils import select_autoescape as select_autoescape
37
+
38
+ __version__ = "3.1.6"
@@ -0,0 +1,6 @@
1
+ import re
2
+
3
+ # generated by scripts/generate_identifier_pattern.py
4
+ pattern = re.compile(
5
+ r"[\w·̀-ͯ·҃-֑҇-ׇֽֿׁׂׅׄؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۤۧۨ-ܑۭܰ-݊ަ-ް߫-߽߳ࠖ-࠙ࠛ-ࠣࠥ-ࠧࠩ-࡙࠭-࡛࣓-ࣣ࣡-ःऺ-़ा-ॏ॑-ॗॢॣঁ-ঃ়া-ৄেৈো-্ৗৢৣ৾ਁ-ਃ਼ਾ-ੂੇੈੋ-੍ੑੰੱੵઁ-ઃ઼ા-ૅે-ૉો-્ૢૣૺ-૿ଁ-ଃ଼ା-ୄେୈୋ-୍ୖୗୢୣஂா-ூெ-ைொ-்ௗఀ-ఄా-ౄె-ైొ-్ౕౖౢౣಁ-ಃ಼ಾ-ೄೆ-ೈೊ-್ೕೖೢೣഀ-ഃ഻഼ാ-ൄെ-ൈൊ-്ൗൢൣංඃ්ා-ුූෘ-ෟෲෳัิ-ฺ็-๎ັິ-ູົຼ່-ໍ༹༘༙༵༷༾༿ཱ-྄྆྇ྍ-ྗྙ-ྼ࿆ါ-ှၖ-ၙၞ-ၠၢ-ၤၧ-ၭၱ-ၴႂ-ႍႏႚ-ႝ፝-፟ᜒ-᜔ᜲ-᜴ᝒᝓᝲᝳ឴-៓៝᠋-᠍ᢅᢆᢩᤠ-ᤫᤰ-᤻ᨗ-ᨛᩕ-ᩞ᩠-᩿᩼᪰-᪽ᬀ-ᬄ᬴-᭄᭫-᭳ᮀ-ᮂᮡ-ᮭ᯦-᯳ᰤ-᰷᳐-᳔᳒-᳨᳭ᳲ-᳴᳷-᳹᷀-᷹᷻-᷿‿⁀⁔⃐-⃥⃜⃡-⃰℘℮⳯-⵿⳱ⷠ-〪ⷿ-゙゚〯꙯ꙴ-꙽ꚞꚟ꛰꛱ꠂ꠆ꠋꠣ-ꠧꢀꢁꢴ-ꣅ꣠-꣱ꣿꤦ-꤭ꥇ-꥓ꦀ-ꦃ꦳-꧀ꧥꨩ-ꨶꩃꩌꩍꩻ-ꩽꪰꪲ-ꪴꪷꪸꪾ꪿꫁ꫫ-ꫯꫵ꫶ꯣ-ꯪ꯬꯭ﬞ︀-️︠-︯︳︴﹍-﹏_𐇽𐋠𐍶-𐍺𐨁-𐨃𐨅𐨆𐨌-𐨏𐨸-𐨿𐨺𐫦𐫥𐴤-𐽆𐴧-𐽐𑀀-𑀂𑀸-𑁆𑁿-𑂂𑂰-𑂺𑄀-𑄂𑄧-𑄴𑅅𑅆𑅳𑆀-𑆂𑆳-𑇀𑇉-𑇌𑈬-𑈷𑈾𑋟-𑋪𑌀-𑌃𑌻𑌼𑌾-𑍄𑍇𑍈𑍋-𑍍𑍗𑍢𑍣𑍦-𑍬𑍰-𑍴𑐵-𑑆𑑞𑒰-𑓃𑖯-𑖵𑖸-𑗀𑗜𑗝𑘰-𑙀𑚫-𑚷𑜝-𑜫𑠬-𑠺𑨁-𑨊𑨳-𑨹𑨻-𑨾𑩇𑩑-𑩛𑪊-𑪙𑰯-𑰶𑰸-𑰿𑲒-𑲧𑲩-𑲶𑴱-𑴶𑴺𑴼𑴽𑴿-𑵅𑵇𑶊-𑶎𑶐𑶑𑶓-𑶗𑻳-𑻶𖫰-𖫴𖬰-𖬶𖽑-𖽾𖾏-𖾒𛲝𛲞𝅥-𝅩𝅭-𝅲𝅻-𝆂𝆅-𝆋𝆪-𝆭𝉂-𝉄𝨀-𝨶𝨻-𝩬𝩵𝪄𝪛-𝪟𝪡-𝪯𞀀-𞀆𞀈-𞀘𞀛-𞀡𞀣𞀤𞀦-𞣐𞀪-𞣖𞥄-𞥊󠄀-󠇯]+" # noqa: B950
6
+ )
@@ -0,0 +1,99 @@
1
+ import inspect
2
+ import typing as t
3
+ from functools import WRAPPER_ASSIGNMENTS
4
+ from functools import wraps
5
+
6
+ from .utils import _PassArg
7
+ from .utils import pass_eval_context
8
+
9
+ if t.TYPE_CHECKING:
10
+ import typing_extensions as te
11
+
12
+ V = t.TypeVar("V")
13
+
14
+
15
+ def async_variant(normal_func): # type: ignore
16
+ def decorator(async_func): # type: ignore
17
+ pass_arg = _PassArg.from_obj(normal_func)
18
+ need_eval_context = pass_arg is None
19
+
20
+ if pass_arg is _PassArg.environment:
21
+
22
+ def is_async(args: t.Any) -> bool:
23
+ return t.cast(bool, args[0].is_async)
24
+
25
+ else:
26
+
27
+ def is_async(args: t.Any) -> bool:
28
+ return t.cast(bool, args[0].environment.is_async)
29
+
30
+ # Take the doc and annotations from the sync function, but the
31
+ # name from the async function. Pallets-Sphinx-Themes
32
+ # build_function_directive expects __wrapped__ to point to the
33
+ # sync function.
34
+ async_func_attrs = ("__module__", "__name__", "__qualname__")
35
+ normal_func_attrs = tuple(set(WRAPPER_ASSIGNMENTS).difference(async_func_attrs))
36
+
37
+ @wraps(normal_func, assigned=normal_func_attrs)
38
+ @wraps(async_func, assigned=async_func_attrs, updated=())
39
+ def wrapper(*args, **kwargs): # type: ignore
40
+ b = is_async(args)
41
+
42
+ if need_eval_context:
43
+ args = args[1:]
44
+
45
+ if b:
46
+ return async_func(*args, **kwargs)
47
+
48
+ return normal_func(*args, **kwargs)
49
+
50
+ if need_eval_context:
51
+ wrapper = pass_eval_context(wrapper)
52
+
53
+ wrapper.jinja_async_variant = True # type: ignore[attr-defined]
54
+ return wrapper
55
+
56
+ return decorator
57
+
58
+
59
+ _common_primitives = {int, float, bool, str, list, dict, tuple, type(None)}
60
+
61
+
62
+ async def auto_await(value: t.Union[t.Awaitable["V"], "V"]) -> "V":
63
+ # Avoid a costly call to isawaitable
64
+ if type(value) in _common_primitives:
65
+ return t.cast("V", value)
66
+
67
+ if inspect.isawaitable(value):
68
+ return await t.cast("t.Awaitable[V]", value)
69
+
70
+ return value
71
+
72
+
73
+ class _IteratorToAsyncIterator(t.Generic[V]):
74
+ def __init__(self, iterator: "t.Iterator[V]"):
75
+ self._iterator = iterator
76
+
77
+ def __aiter__(self) -> "te.Self":
78
+ return self
79
+
80
+ async def __anext__(self) -> V:
81
+ try:
82
+ return next(self._iterator)
83
+ except StopIteration as e:
84
+ raise StopAsyncIteration(e.value) from e
85
+
86
+
87
+ def auto_aiter(
88
+ iterable: "t.Union[t.AsyncIterable[V], t.Iterable[V]]",
89
+ ) -> "t.AsyncIterator[V]":
90
+ if hasattr(iterable, "__aiter__"):
91
+ return iterable.__aiter__()
92
+ else:
93
+ return _IteratorToAsyncIterator(iter(iterable))
94
+
95
+
96
+ async def auto_to_list(
97
+ value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]",
98
+ ) -> t.List["V"]:
99
+ return [x async for x in auto_aiter(value)]