Compare commits

...
Author SHA1 Message Date
Ludy87 5e3529fa5b Style: consolidate multiline expressions across engine
Formatting-only changes across the engine: collapsed several multi-line expressions into single-line forms, tightened f-strings and SQL literals, and removed extraneous line breaks/parentheses. Affected files include the tool model generator, multiple agents (contradiction, ledger, math_presentation, pdf_edit, pdf_questions), document stores (pgvector, sqlite, rag_capability), models (tool_io, tool_models), services (tool_io_compat, tracking), settings, and tests. No functional behavior changes intended—these edits are purely stylistic to improve readability and satisfy formatting/linting.
2026-08-11 18:34:45 +02:00
Ludy87 a2606e07b6 Update pre-commit.yml 2026-08-11 18:31:01 +02:00
Ludy fcb474243c Merge branch 'main' into update_python_dep_20260804 2026-08-11 18:30:06 +02:00
Ludy87 54d1bbe04e Update ruff config; apply formatting & noqa fixes
Relax pre-commit pins and increase ruff line-length in engine/pyproject.toml; add per-file ruff ignores and update engine/uv.lock. Apply many non-functional Python formatting and linting adjustments across scripts and test step files: consolidate multi-line strings/args, collapse long f-strings, reorder/clean imports, add # noqa markers (BLE001, E501, N806 etc.), and minor refactors to improve style and silence linters. No behavioral changes intended — changes are formatting/lint-related to satisfy tooling and reduce warnings.
2026-08-11 18:29:09 +02:00
Ludy b7aa0b07c1 Merge branch 'main' into update_python_dep_20260804 2026-08-11 16:54:26 +02:00
Ludy ef63ba83a2 Merge branch 'main' into update_python_dep_20260804 2026-08-06 15:32:12 +02:00
Ludy87 a59196db69 rm npm 2026-08-04 18:53:33 +02:00
Ludy87 bfa86b099d Install npm in embedded Docker images
Add npm to the apt-get install line in docker/embedded/Dockerfile, Dockerfile.fat and Dockerfile.ultra-lite so npm is available in embedded builds. Also add an npm --version check in the ultra-lite Dockerfile. This ensures frontend/build tooling is present in the embedded images.
2026-08-04 18:30:27 +02:00
Ludy87 4bfc3fbb4b Remove BLE001 noqa comments and adjust type hints
Drop redundant "# noqa: BLE001" inline comments from broad except blocks and update type annotations for async/generator signatures. Changes: remove BLE001 noqa in config.py and service.py, tighten ConcurrencyLimitedModel.request_stream return type in runtime.py to AsyncGenerator[StreamedResponse], simplify test generator annotation in test_config_routes.py, and a minor README formatting tweak. These edits align code with linting/typing rules and improve type correctness.
2026-08-04 18:10:33 +02:00
Ludy87 8be041c4be Use AsyncGenerator/Generator typing
Replace collections.abc AsyncIterator/Iterator with AsyncGenerator/Generator in engine runtime and tests. Update ConcurrencyLimitedModel.request_stream return annotation to AsyncGenerator[StreamedResponse, None] and adjust test_config_routes._client to Generator[TestClient, None, None]. Typing-only change to reflect actual generator/async-generator return shapes; no runtime behaviour changes.
2026-08-04 18:00:59 +02:00
Ludy87 2c11772b7f deps(engine): update Python dependencies 2026-08-04 17:50:58 +02:00
74 changed files with 2011 additions and 768 deletions
+6 -6
View File
@@ -10,10 +10,10 @@ adjusting the format.
Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
""" # noqa: E501
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml # noqa: E501
import argparse
import glob
@@ -223,11 +223,11 @@ def check_for_differences(reference_file, file_list, branch, actor):
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing." # noqa: E501
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed." # noqa: E501
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
@@ -248,7 +248,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to remove them:")
@@ -256,7 +256,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("")
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to add them:")
+26
View File
@@ -83,6 +83,32 @@ jobs:
});
}
- name: Run engine tests with coverage
id: engine-coverage
if: always()
run: task engine:test:coverage
- name: Add engine coverage to step summary
if: always() && steps.engine-coverage.outcome == 'success'
working-directory: engine
run: |
{
echo '## AI Engine coverage'
echo
echo '```text'
uv run coverage report --show-missing
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload engine coverage report
if: always() && steps.engine-coverage.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ai-engine-coverage
path: engine/coverage/
retention-days: 7
if-no-files-found: warn
- name: Fail if engine check failed
if: steps.engine-check.outcome == 'failure'
run: |
+14
View File
@@ -110,6 +110,20 @@ tasks:
cmds:
- uv run --locked --group engine --group engine-dev pytest tests
test:coverage:
desc: "Run tests with coverage reporting"
deps: [prepare]
cmds:
- >-
uv run pytest tests
--cov=src/stirling
--cov-fail-under=90
--cov-report=term-missing
--cov-report=xml:coverage/coverage.xml
--cov-report=html:coverage/html
--cov-report=json:coverage/coverage.json
- uv run python scripts/check_coverage.py coverage/coverage.json --minimum 90
fix:
desc: "Auto-fix lint + format"
cmds:
+4 -3
View File
@@ -7,10 +7,11 @@ vars:
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'scripts/*.py'
'scripts/**/*.py'
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
'testing/**/*.py'
SPELL_FILES: >-
'*.html'
'*.css'
@@ -101,12 +102,12 @@ tasks:
ruff:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff check --config engine/pyproject.toml --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff format --config engine/pyproject.toml --line-length=127 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
@@ -12,7 +12,7 @@ To convert a PDF file to a single WebP image:
To adjust the DPI resolution for rendering PDF pages:
python script.py input.pdf output_directory --dpi 150
"""
""" # noqa: E501
import argparse
import os
@@ -54,14 +54,12 @@ def resize_image(input_image_path, output_image_path, max_size=(16383, 16383)):
# Resize the image
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
resized_image.save(output_image_path, format="WEBP", quality=100)
print(
f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}"
)
print(f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}")
else:
# If dimensions are within the allowed limits, save the image directly
image.save(output_image_path, format="WEBP", quality=100)
print(f"The image was successfully saved as WebP: {output_image_path}")
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"An error occurred: {e}")
@@ -1,8 +1,9 @@
import argparse
import sys
import os
import cv2
import numpy as np
import os
def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000, min_contour_area=500):
mask = cv2.inRange(image, background_color - tolerance, background_color + tolerance)
@@ -57,7 +58,7 @@ def auto_rotate(image, angle_threshold=1):
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
M = cv2.getRotationMatrix2D(center, angle, 1.0) # noqa: N806
return cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
@@ -75,12 +76,12 @@ def crop_borders(image, border_color, tolerance=30):
return image[y:y+h, x:x+w]
def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min_contour_area=500, angle_threshold=10, border_size=0):
def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min_contour_area=500, angle_threshold=10, border_size=0): # noqa: E501
image = cv2.imread(input_file)
background_color = estimate_background_color(image)
# Add a constant border around the image
image = cv2.copyMakeBorder(image, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=background_color)
image = cv2.copyMakeBorder(image, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=background_color) # noqa: E501
photo_boundaries = find_photo_boundaries(image, background_color, tolerance)
@@ -111,12 +112,12 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Split photos in an image")
parser.add_argument("input_file", help="The input scanned image containing multiple photos.")
parser.add_argument("output_directory", help="The directory where the result images should be placed.")
parser.add_argument("--tolerance", type=int, default=30, help="Determines the range of color variation around the estimated background color (default: 30).")
parser.add_argument("--min_area", type=int, default=10000, help="Sets the minimum area threshold for a photo (default: 10000).")
parser.add_argument("--min_contour_area", type=int, default=500, help="Sets the minimum contour area threshold for a photo (default: 500).")
parser.add_argument("--angle_threshold", type=int, default=10, help="Sets the minimum absolute angle required for the image to be rotated (default: 10).")
parser.add_argument("--border_size", type=int, default=0, help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).")
parser.add_argument("--tolerance", type=int, default=30, help="Determines the range of color variation around the estimated background color (default: 30).") # noqa: E501
parser.add_argument("--min_area", type=int, default=10000, help="Sets the minimum area threshold for a photo (default: 10000).") # noqa: E501
parser.add_argument("--min_contour_area", type=int, default=500, help="Sets the minimum contour area threshold for a photo (default: 500).") # noqa: E501
parser.add_argument("--angle_threshold", type=int, default=10, help="Sets the minimum absolute angle required for the image to be rotated (default: 10).") # noqa: E501
parser.add_argument("--border_size", type=int, default=0, help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).") # noqa: E501
args = parser.parse_args()
split_photos(args.input_file, args.output_directory, tolerance=args.tolerance, min_area=args.min_area, min_contour_area=args.min_contour_area, angle_threshold=args.angle_threshold, border_size=args.border_size)
split_photos(args.input_file, args.output_directory, tolerance=args.tolerance, min_area=args.min_area, min_contour_area=args.min_contour_area, angle_threshold=args.angle_threshold, border_size=args.border_size) # noqa: E501
+3
View File
@@ -46,3 +46,6 @@ logs/
# OS
.DS_Store
Thumbs.db
.coverage
coverage/
+16 -9
View File
@@ -10,7 +10,9 @@ dependencies = []
engine = [
"cryptography>=50.0.0",
"fastapi>=0.141.1",
"opentelemetry-sdk>=1.39.1",
"httpx>=0.28.1",
"openai>=2.0.0,<3.0.0",
"opentelemetry-sdk>=1.39.1,<1.44.0",
"pgvector>=0.5.0",
"posthog>=7.38.3",
"psycopg[binary,pool]>=3.3.4",
@@ -21,16 +23,18 @@ engine = [
"pydantic-settings>=2.15.0",
"python-dotenv>=1.2.2",
"sqlite-vec>=0.1.9",
"starlette>=1.3.1",
"uvicorn>=0.52.1",
]
# Type checking, testing, model generation, and formatting tools for the engine.
engine-dev = [
"anyio>=4.14.2",
"datamodel-code-generator[ruff]==0.64.0",
"pyright>=1.1.411",
"datamodel-code-generator[ruff]>=0.72.0",
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
"pyright>=1.1.411",
"referencing>=0.37.0",
"ruff==0.15.5",
"ruff>=0.16.1",
]
# Dependencies for the Cucumber/Python integration test suite.
cucumber = [
@@ -64,9 +68,9 @@ updater-signatures = [
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
"codespell==2.4.2",
"ruff==0.15.5",
"tomli-w==1.2.0",
"codespell>=2.4.2",
"ruff>=0.16.1",
"tomli-w>=1.2.0",
]
[build-system]
@@ -78,10 +82,10 @@ packages = ["src"]
exclude = ["tests"]
[tool.uv]
default-groups = []
default-groups = ["engine", "engine-dev"]
[tool.ruff]
line-length = 120
line-length = 127
target-version = "py313"
[tool.ruff.lint]
@@ -101,6 +105,9 @@ select = [
[tool.ruff.lint.isort]
known-first-party = ["stirling", "tests"]
[tool.ruff.lint.per-file-ignores]
"testing/**/*.py" = ["N803", "BLE001", "E501"]
[tool.pyright]
pythonVersion = "3.13"
reportImportCycles = "warning"
+35
View File
@@ -0,0 +1,35 @@
"""Fail when any measured source file falls below the required coverage."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("report", type=Path)
parser.add_argument("--minimum", type=float, default=90.0)
args = parser.parse_args()
data = json.loads(args.report.read_text(encoding="utf-8"))
failures: list[tuple[str, float]] = []
for filename, details in data["files"].items():
coverage = float(details["summary"]["percent_covered"])
if coverage < args.minimum:
failures.append((filename, coverage))
if failures:
print(f"Per-file coverage below {args.minimum:.1f}%:", file=sys.stderr)
for filename, coverage in sorted(failures):
print(f" {coverage:.1f}% {filename}", file=sys.stderr)
return 1
print(f"Per-file coverage: every file is at least {args.minimum:.1f}%.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -2
View File
@@ -433,8 +433,7 @@ def render_tool_io(spec: dict[str, Any], tools: list[ToolSpec]) -> str:
formats=_members(vocabulary["formats"]),
arities=_members(vocabulary["arities"]),
declarations="\n".join(
f" ToolEndpoint.{by_path[path]}: {_render_spec(declaration)},"
for path, declaration in sorted(table.items())
f" ToolEndpoint.{by_path[path]}: {_render_spec(declaration)}," for path, declaration in sorted(table.items())
),
)
# Formatted before writing so --check compares like for like.
@@ -35,8 +35,7 @@ _CONTRADICTION_INTENT_SYSTEM_PROMPT = (
class _ContradictionIntentDecision(ApiModel):
is_contradiction: bool = Field(
description=(
"True if the prompt is asking about textual contradictions, "
"inconsistencies, or logical conflicts in the document."
"True if the prompt is asking about textual contradictions, inconsistencies, or logical conflicts in the document."
),
)
+1 -3
View File
@@ -544,7 +544,5 @@ class MathAuditorAgent:
if warning_count:
parts.append(f"Found {warning_count} warning{'s' if warning_count != 1 else ''}.")
if unauditable_pages:
parts.append(
f"Pages {', '.join(str(p + 1) for p in unauditable_pages)} could not be audited (OCR unavailable)."
)
parts.append(f"Pages {', '.join(str(p + 1) for p in unauditable_pages)} could not be audited (OCR unavailable).")
return " ".join(parts)
@@ -51,8 +51,7 @@ _MATH_INTENT_SYSTEM_PROMPT = (
class _MathIntentDecision(ApiModel):
is_math: bool = Field(
description=(
"True if the prompt is about verifying numerical content "
"(math, audit, calculations, totals, percentages, etc.)."
"True if the prompt is about verifying numerical content (math, audit, calculations, totals, percentages, etc.)."
),
)
+5 -13
View File
@@ -59,9 +59,7 @@ class PdfEditNeedContentSelection(ApiModel):
max_characters: int | None = None
type PdfEditPlanOutput = (
PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse | PdfEditNeedContentSelection
)
type PdfEditPlanOutput = PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse | PdfEditNeedContentSelection
class PdfEditSelectionAgent:
@@ -152,9 +150,7 @@ class PdfEditParameterSelector:
operation_id = operation_plan[operation_index]
operation_list = ", ".join(operation.name for operation in operation_plan)
generated_steps_text = (
"\n".join(
f"- Step {step_index + 1}: {step.model_dump_json()}" for step_index, step in enumerate(generated_steps)
)
"\n".join(f"- Step {step_index + 1}: {step.model_dump_json()}" for step_index, step in enumerate(generated_steps))
if generated_steps
else "None"
)
@@ -233,8 +229,7 @@ class PdfEditAgent:
return EditCannotDoResponse(
reason=(
"The following operations are not available on this server "
"(either disabled by the administrator or not installed): "
+ ", ".join(op.name for op in unsupported)
"(either disabled by the administrator or not installed): " + ", ".join(op.name for op in unsupported)
)
)
problems = self._chain_problems(selection.operations)
@@ -243,9 +238,7 @@ class PdfEditAgent:
logger.warning("[pdf-edit] plan rejected on attempt %d: %s", attempt + 1, problems)
repair_note = problems
else:
return EditCannotDoResponse(
reason=("No workable order of the available operations achieves this: " + repair_note)
)
return EditCannotDoResponse(reason=("No workable order of the available operations achieves this: " + repair_note))
logger.info("[pdf-edit] plan: %s", [op.name for op in selection.operations])
steps: list[ToolOperationStep] = []
for operation_index, operation_id in enumerate(selection.operations):
@@ -344,8 +337,7 @@ class PdfEditAgent:
else ""
)
unavailable_line = (
"Unavailable operations (exist but not currently usable): "
f"{self._get_operations_prompt(unavailable_operations)}\n"
f"Unavailable operations (exist but not currently usable): {self._get_operations_prompt(unavailable_operations)}\n"
if unavailable_operations
else ""
)
+1 -3
View File
@@ -201,9 +201,7 @@ class PdfQuestionAgent:
provider = self.runtime.settings.chat_provider
agent = Agent(
model=self.runtime.smart_model,
output_type=structured_output(
[PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider
),
output_type=structured_output([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider),
retries=output_retries(provider),
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
@@ -182,9 +182,7 @@ class ChunkedReasoner:
worker_timeout_seconds: float | None = None,
notes_char_budget: int | None = None,
) -> None:
budget = (
notes_char_budget if notes_char_budget is not None else runtime.settings.chunked_reasoner_notes_char_budget
)
budget = notes_char_budget if notes_char_budget is not None else runtime.settings.chunked_reasoner_notes_char_budget
if budget <= 0:
raise ValueError("notes_char_budget must be positive")
self._runtime = runtime
+1 -1
View File
@@ -218,7 +218,7 @@ async def apply_config(request: ConfigPushRequest, http_request: Request) -> Con
save_config(request)
# Claim the stamp we just wrote so this worker's watcher does not rebuild for it.
app.state.config_cache_stamp = cache_stamp()
except Exception: # noqa: BLE001 - best-effort persist, never fail the applied push
except Exception:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append(
"Config applied on this worker but could not be persisted; it will not survive an"
+1 -3
View File
@@ -58,9 +58,7 @@ class AppSettings(BaseSettings):
# Chunked reasoner settings (whole-document map-reduce).
chunked_reasoner_chars_per_slice: int = Field(validation_alias="STIRLING_CHUNKED_REASONER_CHARS_PER_SLICE")
chunked_reasoner_concurrency: int = Field(validation_alias="STIRLING_CHUNKED_REASONER_CONCURRENCY")
chunked_reasoner_worker_timeout_seconds: float = Field(
validation_alias="STIRLING_CHUNKED_REASONER_WORKER_TIMEOUT_SECONDS"
)
chunked_reasoner_worker_timeout_seconds: float = Field(validation_alias="STIRLING_CHUNKED_REASONER_WORKER_TIMEOUT_SECONDS")
# Maximum size, in characters, of the rendered notes block before the
# reasoner folds slice notes hierarchically. The Anthropic context limit
# is 200k tokens (~880k chars); we leave a generous margin for the
+2 -5
View File
@@ -88,9 +88,7 @@ class Folio(ApiModel):
text: str | None = Field(default=None, description="PDFBox plain-text extraction.")
tables: list[str] | None = Field(default=None, description="Tabula CSV strings, one per table found on the page.")
ocr_text: str | None = Field(default=None, description="OCRmyPDF output text.")
ocr_confidence: float | None = Field(
default=None, ge=0.0, le=1.0, description="Mean character confidence from OCRmyPDF."
)
ocr_confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Mean character confidence from OCRmyPDF.")
@property
def readable_text(self) -> str:
@@ -110,8 +108,7 @@ class Evidence(ApiModel):
round: int = Field(ge=1, le=3)
final_round: bool = Field(
default=False,
description="When True, Java will not honour further Requisitions. "
"The auditor must return a Verdict this round.",
description="When True, Java will not honour further Requisitions. The auditor must return a Verdict this round.",
)
unauditable_pages: list[int] = Field(
default_factory=list,
@@ -139,12 +139,8 @@ class PdfCommentReport(ApiModel):
so this never re-enters the orchestrator as a resume artifact).
"""
annotations_applied: int = Field(
ge=0, description="Number of sticky-note annotations actually written into the PDF."
)
annotations_applied: int = Field(ge=0, description="Number of sticky-note annotations actually written into the PDF.")
instructions_received: int = Field(
ge=0, description="Number of comment instructions the engine produced before filtering."
)
rationale: str | None = Field(
default=None, description="One-sentence summary the engine emitted alongside the comments."
)
rationale: str | None = Field(default=None, description="One-sentence summary the engine emitted alongside the comments.")
+1
View File
@@ -17,6 +17,7 @@ from pydantic_ai import Agent
from stirling.services import AppRuntime
class MyAgent:
def __init__(self, runtime: AppRuntime) -> None:
rag = runtime.rag_capability
@@ -91,8 +91,7 @@ class PgVectorStore(DocumentStore):
# Partial index over rows that can actually expire keeps the reaper
# scan tight even when most rows are persistent (org docs).
await cur.execute(
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at "
"ON documents_meta(expires_at) WHERE expires_at IS NOT NULL"
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at ON documents_meta(expires_at) WHERE expires_at IS NOT NULL"
)
await cur.execute(
"""
@@ -109,9 +108,7 @@ class PgVectorStore(DocumentStore):
)
"""
)
await cur.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_collection_owner ON rag_documents(collection, owner_id)"
)
await cur.execute("CREATE INDEX IF NOT EXISTS idx_rag_collection_owner ON rag_documents(collection, owner_id)")
await cur.execute(
"""
CREATE TABLE IF NOT EXISTS document_pages (
@@ -149,7 +149,5 @@ class RagCapability:
source = result.document.metadata.get("source", "unknown")
chunk_idx = result.document.metadata.get("chunk_index", "?")
score = f"{result.score:.3f}"
sections.append(
f"[Result {i} | source: {source}, chunk: {chunk_idx}, relevance: {score}]\n{result.document.text}"
)
sections.append(f"[Result {i} | source: {source}, chunk: {chunk_idx}, relevance: {score}]\n{result.document.text}")
return "\n\n---\n\n".join(sections)
+1 -1
View File
@@ -139,7 +139,7 @@ class DocumentService:
try:
results = await self._store.search(col_name, query_embedding, k, principals)
all_results.extend(results)
except Exception: # noqa: BLE001 - any backend error on one collection should not stop the others
except Exception:
logger.warning(
"Skipping collection %s during cross-collection search",
col_name,
@@ -130,9 +130,7 @@ class SqliteVecStore(DocumentStore):
)
"""
)
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pages_collection_owner ON document_pages(collection, owner_id)"
)
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_collection_owner ON document_pages(collection, owner_id)")
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS document_acl (
@@ -148,9 +146,7 @@ class SqliteVecStore(DocumentStore):
)
# Lookup by principal is the hot path for search/list (every read
# joins through this index). Composite ordering matches the WHERE.
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)"
)
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)")
self._conn.commit()
# ── lifecycle of the (collection, owner_id) row ────────────────────────
@@ -211,8 +207,7 @@ class SqliteVecStore(DocumentStore):
def _sync_purge_owner(self, owner_id: OwnerId) -> int:
# Drop all vec0 virtual tables for this owner first (FK cascade can't reach them).
vec_tables = [
r[0]
for r in self._conn.execute("SELECT table_name FROM collections WHERE owner_id = ?", (owner_id,)).fetchall()
r[0] for r in self._conn.execute("SELECT table_name FROM collections WHERE owner_id = ?", (owner_id,)).fetchall()
]
for name in vec_tables:
self._conn.execute(f"DROP TABLE IF EXISTS {name}")
@@ -239,9 +234,7 @@ class SqliteVecStore(DocumentStore):
]
for name in vec_tables:
self._conn.execute(f"DROP TABLE IF EXISTS {name}")
cursor = self._conn.execute(
"DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < datetime('now')"
)
cursor = self._conn.execute("DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < datetime('now')")
self._conn.commit()
return cursor.rowcount
@@ -344,8 +337,7 @@ class SqliteVecStore(DocumentStore):
)
if pages:
self._conn.executemany(
"INSERT INTO document_pages(collection, owner_id, page_number, text, char_count) "
"VALUES (?, ?, ?, ?, ?)",
"INSERT INTO document_pages(collection, owner_id, page_number, text, char_count) VALUES (?, ?, ?, ?, ?)",
[(collection, owner_id, p.page_number, p.text, p.char_count) for p in pages],
)
self._conn.commit()
+13 -39
View File
@@ -109,13 +109,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
)
],
),
ToolEndpoint.PDF_TO_MARKDOWN: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.MARKDOWN, arity=ToolArity.SISO
),
ToolEndpoint.PDF_TO_MARKDOWN: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.MARKDOWN, arity=ToolArity.SISO),
ToolEndpoint.PDF_TO_PDFA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.PDF_TO_PRESENTATION: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PPT, arity=ToolArity.SISO
),
ToolEndpoint.PDF_TO_PRESENTATION: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PPT, arity=ToolArity.SISO),
ToolEndpoint.PDF_TO_TEXT: ToolIOSpec(
accepts=[ToolFormat.PDF],
produces=ToolFormat.TEXT,
@@ -164,40 +160,24 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
],
),
ToolEndpoint.URL_TO_PDF: ToolIOSpec(accepts=[ToolFormat.NONE], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.VECTOR_TO_PDF: ToolIOSpec(
accepts=[ToolFormat.POSTSCRIPT], produces=ToolFormat.PDF, arity=ToolArity.SISO
),
ToolEndpoint.BOOKLET_IMPOSITION: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO
),
ToolEndpoint.VECTOR_TO_PDF: ToolIOSpec(accepts=[ToolFormat.POSTSCRIPT], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.BOOKLET_IMPOSITION: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.CROP: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.EDIT_TABLE_OF_CONTENTS: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO
),
ToolEndpoint.EDIT_TABLE_OF_CONTENTS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.EDIT_TEXT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.MERGE_PDFS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.MISO),
ToolEndpoint.MULTI_PAGE_LAYOUT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.PDF_TO_SINGLE_PAGE: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO
),
ToolEndpoint.PDF_TO_SINGLE_PAGE: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.REARRANGE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.REMOVE_IMAGE_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.REMOVE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.ROTATE_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.SCALE_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.SPLIT_BY_SIZE_OR_COUNT: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO
),
ToolEndpoint.SPLIT_FOR_POSTER_PRINT: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO
),
ToolEndpoint.SPLIT_BY_SIZE_OR_COUNT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.SPLIT_FOR_POSTER_PRINT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.SPLIT_PAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO
),
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO
),
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.ADD_COMMENTS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.ADD_PAGE_NUMBERS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.ADD_STAMP: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
@@ -217,12 +197,8 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
ToolEndpoint.AUTO_SPLIT_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.COMPRESS_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.DELETE_ATTACHMENT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.EXTRACT_ATTACHMENTS: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.ZIP, arity=ToolArity.SISO
),
ToolEndpoint.EXTRACT_IMAGE_SCANS: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, arity=ToolArity.SIMO
),
ToolEndpoint.EXTRACT_ATTACHMENTS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.ZIP, arity=ToolArity.SISO),
ToolEndpoint.EXTRACT_IMAGE_SCANS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, arity=ToolArity.SIMO),
ToolEndpoint.EXTRACT_IMAGES: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, arity=ToolArity.SIMO),
ToolEndpoint.FLATTEN: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.OCR_PDF: ToolIOSpec(
@@ -240,9 +216,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
ToolEndpoint.REMOVE_BLANKS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SIMO),
ToolEndpoint.RENAME_ATTACHMENT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.REPAIR: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.REPLACE_INVERT_PDF: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO
),
ToolEndpoint.REPLACE_INVERT_PDF: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.SCANNER_EFFECT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.UNLOCK_PDF_FORMS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.UPDATE_METADATA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
+8 -24
View File
@@ -460,15 +460,11 @@ class EditTextParams(ApiModel):
class EmlToPdfParams(ApiModel):
download_html: bool | None = Field(
None, description="Download HTML intermediate file instead of PDF", examples=[False]
)
download_html: bool | None = Field(None, description="Download HTML intermediate file instead of PDF", examples=[False])
include_all_recipients: bool | None = Field(
None, description="Include CC and BCC recipients in header (if available)", examples=[True]
)
include_attachments: bool | None = Field(
None, description="Include email attachments in the PDF output", examples=[False]
)
include_attachments: bool | None = Field(None, description="Include email attachments in the PDF output", examples=[False])
max_attachment_size_mb: int | None = Field(
None,
description="Maximum attachment size in MB to include (default 10MB, range: 1-100)",
@@ -554,13 +550,9 @@ class FitOption(StrEnum):
class ImgToPdfParams(ApiModel):
auto_rotate: bool = Field(
False, description="Whether to automatically rotate the images to better fit the PDF page"
)
auto_rotate: bool = Field(False, description="Whether to automatically rotate the images to better fit the PDF page")
color_type: ColorType = Field(ColorType.color, description="The color type of the output image(s)")
fit_option: FitOption = Field(
FitOption.fill_page, description="Option to determine how the image will fit onto the page"
)
fit_option: FitOption = Field(FitOption.fill_page, description="Option to determine how the image will fit onto the page")
class MarkdownToPdfParams(ApiModel):
@@ -663,9 +655,7 @@ class MultiPageLayoutParams(ApiModel):
left_margin: float = Field(
0, description="Left margin (in points) to apply to the output pages when merging", examples=[200], ge=0.0
)
mode: Mode = Field(
Mode.default, description="Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows."
)
mode: Mode = Field(Mode.default, description="Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows.")
orientation: Orientation = Field(Orientation.portrait, description="The orientation of the output PDF pages")
pages_per_sheet: PagesPerSheet1 | None = Field(
None, description="The number of pages to fit onto a single sheet in the output PDF."
@@ -848,9 +838,7 @@ class OutputFormat1(StrEnum):
class PdfToPdfaParams(ApiModel):
output_format: OutputFormat1 = Field(..., description="The output format type (PDF/A or PDF/X)")
strict: bool | None = Field(
None, description="If true, the conversion will fail if the output is not perfectly compliant"
)
strict: bool | None = Field(None, description="If true, the conversion will fail if the output is not perfectly compliant")
class OutputFormat2(StrEnum):
@@ -1209,12 +1197,8 @@ class ScannerEffectParams(ApiModel):
class SplitBySizeOrCountParams(ApiModel):
split_type: int = Field(
0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count"
)
split_value: str = Field(
"10MB", description="Value for split: size in MB (e.g., '10MB') or number of pages (e.g., '5')"
)
split_type: int = Field(0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count")
split_value: str = Field("10MB", description="Value for split: size in MB (e.g., '10MB') or number of pages (e.g., '5')")
class PageSize1(StrEnum):
@@ -154,18 +154,14 @@ def validate_tool_chain(
return diagnostics
def _check_transition(
index: int, step: ToolChainStep, spec: ToolIOSpec, previous: ResolvedOutput
) -> list[ToolDiagnostic]:
def _check_transition(index: int, step: ToolChainStep, spec: ToolIOSpec, previous: ResolvedOutput) -> list[ToolDiagnostic]:
if previous.format == ToolFormat.NONE:
return [
ToolDiagnostic(
step_index=index,
severity=DiagnosticSeverity.ERROR,
code=DiagnosticCode.FORMAT_MISMATCH,
message=(
f"The previous step returns a report rather than a file, so {step.operation} has nothing to run on."
),
message=(f"The previous step returns a report rather than a file, so {step.operation} has nothing to run on."),
)
]
@@ -188,8 +184,7 @@ def _check_transition(
severity=DiagnosticSeverity.WARN,
code=DiagnosticCode.OUTPUT_UNCERTAIN,
message=(
"The previous step's output depends on how it is configured, "
f"so {step.operation} may not be able to run."
f"The previous step's output depends on how it is configured, so {step.operation} may not be able to run."
),
)
]
+1 -3
View File
@@ -163,9 +163,7 @@ class PostHogSpanProcessor(SpanProcessor):
return properties
def _maybe_emit_trace_event(
self, span: ReadableSpan, attrs: Mapping[str, Any], properties: dict[str, object]
) -> None:
def _maybe_emit_trace_event(self, span: ReadableSpan, attrs: Mapping[str, Any], properties: dict[str, object]) -> None:
"""Emit an $ai_trace event for the first span seen per trace ID."""
trace_id = str(properties.get("$ai_trace_id", ""))
if not trace_id or trace_id in self._seen_traces:
@@ -0,0 +1,183 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from stirling.agents.orchestrator import OrchestratorAgent, _RouteDecision
from stirling.contracts import (
AiFile,
ConversationMessage,
ExtractedFileText,
ExtractedTextArtifact,
OrchestratorRequest,
PdfTextSelection,
SupportedCapability,
)
from stirling.models import FileId
# The tests intentionally replace the pydantic-ai router with a small async
# fake; those substitutions are runtime-safe but do not match production types.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
def _request(*, artifact: bool = False) -> OrchestratorRequest:
artifacts = []
if artifact:
artifacts.append(
ExtractedTextArtifact(files=[ExtractedFileText(file_name="a.pdf", pages=[PdfTextSelection(text="page")])])
)
return OrchestratorRequest(
user_message="do something",
files=[AiFile(id=FileId("file-1"), name="a.pdf")],
artifacts=artifacts,
)
def _agent() -> OrchestratorAgent:
agent = object.__new__(OrchestratorAgent)
agent.runtime = None
return agent
def test_build_prompt_describes_history_files_and_artifacts() -> None:
agent = _agent()
request = _request(artifact=True).model_copy(
update={"conversation_history": [ConversationMessage(role="user", content="hi")]}
)
prompt = agent._build_prompt(request)
assert "do something" in prompt
assert "a.pdf" in prompt
assert "extracted_text: 1 pages" in prompt
assert "- user: hi" in prompt
assert agent._describe_artifacts(_request()) == "- none"
@pytest.mark.anyio
@pytest.mark.parametrize(
"capability, method",
[
(SupportedCapability.PDF_QUESTION, "_run_pdf_question"),
(SupportedCapability.PDF_REVIEW, "_run_pdf_review"),
(SupportedCapability.PDF_EDIT, "_run_pdf_edit"),
(SupportedCapability.AGENT_DRAFT, "_run_agent_draft"),
(SupportedCapability.PDF_CREATE, "_run_pdf_create"),
],
)
async def test_resume_dispatches_each_supported_capability(capability: SupportedCapability, method: str) -> None:
agent = _agent()
expected = object()
handler = AsyncMock(return_value=expected)
setattr(agent, method, handler)
result = await agent._resume(_request(), capability)
assert result is expected
handler.assert_awaited_once()
@pytest.mark.anyio
async def test_resume_rejects_capabilities_that_cannot_resume() -> None:
agent = _agent()
with pytest.raises(ValueError, match="Cannot resume"):
await agent._resume(_request(), SupportedCapability.ORCHESTRATE)
@pytest.mark.anyio
@pytest.mark.parametrize("capability", ["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create"])
async def test_enum_router_dispatches_by_capability(capability: str) -> None:
agent = _agent()
request = _request()
decision = _RouteDecision(capability=capability)
agent._router = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output=decision)))
handler = AsyncMock(return_value=object())
handler_name = {
"pdf_edit": "_run_pdf_edit",
"pdf_question": "_run_pdf_question",
"user_spec": "_run_agent_draft",
"pdf_review": "_run_pdf_review",
"pdf_create": "_run_pdf_create",
}[capability]
setattr(agent, handler_name, handler)
result = await agent._route_and_dispatch(request)
assert result is handler.return_value
handler.assert_awaited_once_with(request)
@pytest.mark.anyio
async def test_enum_router_returns_helpful_unsupported_message() -> None:
agent = _agent()
agent._router = SimpleNamespace(
run=AsyncMock(return_value=SimpleNamespace(output=_RouteDecision(capability="unsupported")))
)
result = await agent._route_and_dispatch(_request())
assert result.model_dump()["message"] == "I can't help with that request."
@pytest.mark.anyio
async def test_delegate_helpers_forward_to_their_handlers() -> None:
agent = _agent()
request = _request()
ctx = SimpleNamespace(deps=SimpleNamespace(request=request))
for delegate, handler_name in (
(agent.delegate_pdf_edit, "_run_pdf_edit"),
(agent.delegate_pdf_question, "_run_pdf_question"),
(agent.delegate_user_spec, "_run_agent_draft"),
(agent.delegate_pdf_review, "_run_pdf_review"),
(agent.delegate_pdf_create, "_run_pdf_create"),
):
handler = AsyncMock(return_value=object())
setattr(agent, handler_name, handler)
assert await delegate(ctx) is handler.return_value
handler.assert_awaited_once_with(request)
@pytest.mark.anyio
async def test_handle_uses_resume_and_model_router_paths() -> None:
agent = _agent()
request = _request()
resumed = object()
agent._resume = AsyncMock(return_value=resumed)
request_with_resume = request.model_copy(update={"resume_with": SupportedCapability.PDF_EDIT})
assert await agent.handle(request_with_resume) is resumed
routed = object()
agent._router = None
agent.agent = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output=routed)))
assert await agent.handle(request) is routed
@pytest.mark.anyio
async def test_delegate_target_constructors_are_called(monkeypatch: pytest.MonkeyPatch) -> None:
agent = _agent()
request = _request()
class Delegate:
def __init__(self, _runtime: object) -> None:
self.orchestrate = AsyncMock(return_value=object())
for name in ("PdfEditAgent", "PdfQuestionAgent", "UserSpecAgent", "PdfReviewAgent", "PdfCreateAgent"):
monkeypatch.setattr(f"stirling.agents.orchestrator.{name}", Delegate)
assert await agent._run_pdf_edit(request) is not None
assert await agent._run_pdf_question(request) is not None
assert await agent._run_agent_draft(request) is not None
assert await agent._run_pdf_review(request) is not None
assert await agent._run_pdf_create(request) is not None
@pytest.mark.anyio
async def test_unsupported_capability_preserves_input() -> None:
agent = _agent()
response = await agent.unsupported_capability(SimpleNamespace(), "custom", "not supported")
assert response.capability == "custom"
assert response.message == "not supported"
+5 -15
View File
@@ -275,9 +275,7 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
written = _written_sections()
with (
agent._meta_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
),
agent._meta_planner.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())),
agent._sections_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
),
@@ -315,9 +313,7 @@ async def test_orchestrate_empty_sections_returns_cannot_do(agent: PdfCreateAgen
empty_sections = DocumentSections(sections=[])
with (
agent._meta_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
),
agent._meta_planner.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())),
agent._sections_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=empty_sections.model_dump_json())
),
@@ -353,9 +349,7 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
)
with (
agent._meta_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
),
agent._meta_planner.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())),
agent._sections_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
),
@@ -384,9 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
written = _written_sections()
with (
agent._meta_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
),
agent._meta_planner.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())),
agent._sections_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
),
@@ -429,9 +421,7 @@ async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -
written = _written_sections()
with (
agent._meta_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())
),
agent._meta_planner.override(model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=meta.model_dump_json())),
agent._sections_planner.override(
model=TestModel(profile=_NATIVE_PROFILE, custom_output_text=sections.model_dump_json())
),
@@ -0,0 +1,74 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from stirling.agents.pdf_edit import (
PdfEditAgent,
PdfEditNeedContentSelection,
PdfEditParameterSelector,
PdfEditSelectionAgent,
)
from stirling.contracts import AiFile, OrchestratorRequest, PdfEditRequest
from stirling.models import FileId, ToolEndpoint
from stirling.services.runtime import AppRuntime
# These tests replace pydantic-ai agents with small async fakes.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
def test_pdf_edit_parameter_prompt_and_selection_instructions() -> None:
selector = object.__new__(PdfEditParameterSelector)
request = PdfEditRequest(user_message="convert this", files=[AiFile(id=FileId("id"), name="a.pdf")])
prompt = selector._build_parameter_prompt(request, [ToolEndpoint.PDF_TO_TEXT], 0, [])
assert "convert this" in prompt
assert "PDF_TO_TEXT" in selector._get_operation_instructions(ToolEndpoint.PDF_TO_TEXT)
@pytest.mark.anyio
async def test_pdf_edit_selection_and_parameter_agents_forward_model_output() -> None:
selection = object.__new__(PdfEditSelectionAgent)
selection.agent = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output="selection")))
assert await selection.select("prompt") == "selection"
parameter = object.__new__(PdfEditParameterSelector)
parameter.agent = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output="parameters")))
request = PdfEditRequest(user_message="convert")
assert await parameter.select(request, [ToolEndpoint.PDF_TO_TEXT], 0, []) == "parameters"
def test_pdf_edit_agent_builds_selection_and_need_content_responses(runtime: AppRuntime) -> None:
agent = object.__new__(PdfEditAgent)
agent.runtime = runtime
request = PdfEditRequest(
user_message="inspect",
files=[AiFile(id=FileId("id"), name="a.pdf")],
)
selection = PdfEditNeedContentSelection(reason="need text", file_names=["a.pdf"], max_pages=2)
response = agent._build_need_content_response(selection, request)
assert response.files[0].file.name == "a.pdf"
assert agent._build_need_content_response(PdfEditNeedContentSelection(reason="all"), request).files[0].file.name == "a.pdf"
assert (
agent._build_need_content_response(PdfEditNeedContentSelection(reason="fallback", file_names=["missing.pdf"]), request)
.files[0]
.file.name
== "a.pdf"
)
selection_agent = agent._build_selection_agent(
[ToolEndpoint.PDF_TO_TEXT], [ToolEndpoint.MERGE_PDFS], allow_need_content=True
)
assert isinstance(selection_agent, PdfEditSelectionAgent)
@pytest.mark.anyio
async def test_pdf_edit_orchestrate_adapts_request() -> None:
agent = object.__new__(PdfEditAgent)
expected = object()
agent.handle = AsyncMock(return_value=expected)
request = OrchestratorRequest(user_message="convert", files=[AiFile(id=FileId("id"), name="a.pdf")])
assert await agent.orchestrate(request) is expected
agent.handle.assert_awaited_once()
@@ -0,0 +1,70 @@
from __future__ import annotations
import asyncio
import pytest
from stirling.api.routes.orchestrator import (
_ErrorFrame,
_HeartbeatFrame,
_OrchestratorStream,
_ProgressFrame,
_ResultFrame,
_serialize_frame,
)
from stirling.contracts import OrchestratorRequest, UnsupportedCapabilityResponse, WholeDocReadDone
# The stream is exercised with a minimal agent double.
# pyright: reportArgumentType=false
def test_serialize_stream_frames() -> None:
event = WholeDocReadDone(completed=1, slices=1, duration_seconds=0.1)
assert b'"event": "progress"' in _serialize_frame(_ProgressFrame(event))
response = UnsupportedCapabilityResponse(capability="test", message="no")
assert b'"event": "result"' in _serialize_frame(_ResultFrame(response))
assert b'"event": "error"' in _serialize_frame(_ErrorFrame("failed"))
assert b'"event": "heartbeat"' in _serialize_frame(_HeartbeatFrame())
@pytest.mark.anyio
async def test_stream_emits_result_and_error_frames() -> None:
request = OrchestratorRequest(user_message="hello")
success_agent = SimpleAgent(UnsupportedCapabilityResponse(capability="test", message="ok"))
stream = _OrchestratorStream(agent=success_agent, request=request, heartbeat_interval_seconds=100)
frames = [frame async for frame in stream.iterate()]
assert any(b'"event": "result"' in frame for frame in frames)
failing_agent = SimpleAgent(RuntimeError("boom"))
stream = _OrchestratorStream(agent=failing_agent, request=request, heartbeat_interval_seconds=100)
frames = [frame async for frame in stream.iterate()]
assert any(b'"event": "error"' in frame for frame in frames)
class SimpleAgent:
def __init__(self, result: object) -> None:
self.result = result
async def handle(self, _request: OrchestratorRequest) -> object:
if isinstance(self.result, BaseException):
raise self.result
return self.result
@pytest.mark.anyio
async def test_stream_progress_and_task_cancellation() -> None:
stream = _OrchestratorStream(
agent=SimpleAgent(UnsupportedCapabilityResponse(capability="test", message="ok")),
request=OrchestratorRequest(user_message="hello"),
heartbeat_interval_seconds=0.001,
)
event = WholeDocReadDone(completed=1, slices=1, duration_seconds=0.1)
await stream._emit_progress(event)
assert isinstance(await stream._queue.get(), _ProgressFrame)
heartbeat = asyncio.create_task(stream._emit_heartbeats())
await asyncio.sleep(0.005)
await stream._cancel_task(heartbeat)
done = asyncio.create_task(asyncio.sleep(0))
await done
await stream._cancel_task(done)
+8 -24
View File
@@ -198,9 +198,7 @@ async def test_canonicaliser_accepts_empty_alias_list(runtime: AppRuntime, file_
),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")])
detector._subject_canonicaliser.run = AsyncMock(return_value=_stub_result(_SubjectMapping(aliases=[])))
detector._pair_detector.run = AsyncMock(
return_value=_stub_result(
@@ -326,9 +324,7 @@ async def test_canonicaliser_failure_falls_back_to_lexical_keys(
),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")])
detector._subject_canonicaliser.run = AsyncMock(side_effect=failure)
detector._pair_detector.run = AsyncMock(
return_value=_stub_result(
@@ -374,9 +370,7 @@ async def test_same_page_contradiction_is_surfaced(runtime: AppRuntime, file_a:
),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1], output=extracted_chunk, label="pages=1")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1], output=extracted_chunk, label="pages=1")])
detector._subject_canonicaliser.run = AsyncMock(
return_value=_stub_result(_SubjectMapping(aliases=[_SubjectAlias(raw="deadline", canonical="deadline")]))
)
@@ -418,17 +412,13 @@ async def test_identical_quote_pair_is_still_dropped(runtime: AppRuntime, file_a
_ExtractedClaim(page=2, subject="topic", polarity="deny", text="y", quote="Shared quote."),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1,2")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1,2")])
detector._subject_canonicaliser.run = AsyncMock(
return_value=_stub_result(_SubjectMapping(aliases=[_SubjectAlias(raw="topic", canonical="topic")]))
)
detector._pair_detector.run = AsyncMock(
return_value=_stub_result(
_BucketContradictions(
pairs=[_DetectedPair(i=0, j=1, explanation="self", severity=ContradictionSeverity.WARNING)]
)
_BucketContradictions(pairs=[_DetectedPair(i=0, j=1, explanation="self", severity=ContradictionSeverity.WARNING)])
)
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
@@ -497,9 +487,7 @@ async def test_detector_chunk_timeout_falls_through(runtime: AppRuntime, file_a:
),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1, 2], output=extracted_chunk, label="pages=1-2")])
detector._subject_canonicaliser.run = AsyncMock(
return_value=_stub_result(_SubjectMapping(aliases=[_SubjectAlias(raw="deadline", canonical="deadline")]))
)
@@ -542,9 +530,7 @@ async def test_empty_chunk_with_substantial_content_logs_warning(
with caplog.at_level(logging.WARNING, logger="stirling.agents.contradiction.detector"):
await detector.detect([file_a], principals=PRINCIPALS)
assert any(
"produced 0 claims" in record.getMessage() and "pages=1" in record.getMessage() for record in caplog.records
)
assert any("produced 0 claims" in record.getMessage() and "pages=1" in record.getMessage() for record in caplog.records)
@pytest.mark.anyio
@@ -580,9 +566,7 @@ async def test_pages_examined_includes_every_attempted_page(runtime: AppRuntime,
),
]
)
detector._mapper.map_pages = AsyncMock(
return_value=[ChunkOutput(pages=[1, 2, 3], output=extracted, label="pages=1-3")]
)
detector._mapper.map_pages = AsyncMock(return_value=[ChunkOutput(pages=[1, 2, 3], output=extracted, label="pages=1-3")])
detector._subject_canonicaliser.run = AsyncMock(return_value=_stub_result(_SubjectMapping(aliases=[])))
detector._pair_detector.run = AsyncMock(return_value=_stub_result(_BucketContradictions(pairs=[])))
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pydantic_ai.exceptions import AgentRunError
from stirling.agents.ledger.agent import (
ExtractedFigure,
FigureExtractionResult,
FormulaCheck,
MathAuditorAgent,
StatementCheck,
StatementsResult,
TableFormulas,
)
from stirling.contracts.ledger import Evidence, Folio, FolioManifest, FolioType, Requisition
# These tests replace the LLM agents with deterministic async doubles.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
def _agent() -> MathAuditorAgent:
agent = object.__new__(MathAuditorAgent)
agent._llm_semaphore = __import__("asyncio").Semaphore(10)
return agent
@pytest.mark.anyio
async def test_examine_forwards_manifest_to_examiner() -> None:
agent = _agent()
request = FolioManifest(session_id="session", page_count=1, folio_types=[FolioType.TEXT])
expected = SimpleNamespace(output=Requisition(need_text=[0], rationale="text"))
agent._examiner = SimpleNamespace(run=AsyncMock(return_value=expected))
result = await agent.examine(request)
assert result is expected.output
agent._examiner.run.assert_awaited_once()
@pytest.mark.anyio
async def test_audit_processes_arithmetic_formulas_figures_and_statements() -> None:
agent = _agent()
agent._infer_formulas = AsyncMock(
return_value=TableFormulas(
formulas=[
FormulaCheck(
description="total",
formula="col1 = col2 * col3",
scope="each_row",
)
]
)
)
agent._extract_figures_for_page = AsyncMock(
return_value=[
(ExtractedFigure(label="Revenue", value="100", raw="100"), 0),
(ExtractedFigure(label="Bad", value="not numeric", raw="x"), 0),
]
)
agent._verify_statements = AsyncMock(
return_value=StatementsResult(
statements=[
StatementCheck(
claim="claim",
verification="comparison",
expected_result="100",
actual_claim="200",
is_valid=False,
explanation="wrong",
)
]
)
)
agent._generate_summary = AsyncMock(return_value="summary")
evidence = Evidence(
session_id="session",
round=2,
final_round=True,
unauditable_pages=[3],
folios=[
Folio(page=0, text="Revenue 100", tables=["Item,Total,Qty,Price\nA,99,2,3"]),
Folio(page=1, text=" "),
],
)
verdict = await agent.audit(evidence)
assert verdict.summary == "summary"
assert verdict.pages_examined == [0, 1]
assert verdict.unauditable_pages == [3]
assert verdict.error_count >= 1
agent._generate_summary.assert_awaited_once()
@pytest.mark.anyio
async def test_internal_llm_helpers_return_structured_outputs() -> None:
agent = _agent()
agent._table_analyser = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output=TableFormulas(formulas=[]))))
agent._statement_verifier = SimpleNamespace(
run=AsyncMock(return_value=SimpleNamespace(output=StatementsResult(statements=[])))
)
agent._figure_extractor = SimpleNamespace(
run=AsyncMock(return_value=SimpleNamespace(output=FigureExtractionResult(figures=[])))
)
assert await agent._infer_formulas("a,b\n1,2") == TableFormulas(formulas=[])
assert await agent._verify_statements(Folio(page=0, text="text", tables=["a,b\n1,2"])) == StatementsResult(statements=[])
assert await agent._verify_statements(Folio(page=0, text=" ")) == StatementsResult(statements=[])
assert await agent._extract_figures_for_page(Folio(page=0, text="text")) == []
assert await agent._extract_figures_for_page(Folio(page=0, text=" ")) == []
@pytest.mark.anyio
async def test_summary_and_throttle_helpers() -> None:
agent = _agent()
agent._summary_agent = SimpleNamespace(run=AsyncMock(return_value=SimpleNamespace(output="model summary")))
discrepancy = SimpleNamespace(severity="warning", page=0, description="warning")
result = await agent._generate_summary([discrepancy], [0], [1], "stats")
assert result == "model summary"
assert await agent._throttled(_value()) == "value"
@pytest.mark.anyio
async def test_audit_skips_failed_subtasks_and_summary_falls_back() -> None:
agent = _agent()
agent._infer_formulas = AsyncMock(return_value=TableFormulas(formulas=[]))
agent._extract_figures_for_page = AsyncMock(side_effect=RuntimeError("figures failed"))
agent._verify_statements = AsyncMock(side_effect=RuntimeError("statements failed"))
agent._summary_agent = SimpleNamespace(run=AsyncMock(side_effect=AgentRunError("summary failed")))
evidence = Evidence(
session_id="session",
round=1,
folios=[Folio(page=0, text="text", tables=["a,b\n1,2"])],
)
verdict = await agent.audit(evidence)
assert "No mathematical errors" in verdict.summary
@pytest.mark.anyio
async def test_agent_helpers_handle_provider_failures() -> None:
agent = _agent()
agent._table_analyser = SimpleNamespace(run=AsyncMock(side_effect=AgentRunError("formula failed")))
agent._statement_verifier = SimpleNamespace(run=AsyncMock(side_effect=AgentRunError("statement failed")))
agent._figure_extractor = SimpleNamespace(run=AsyncMock(side_effect=AgentRunError("figure failed")))
assert await agent._infer_formulas("a,b\n1,2") == TableFormulas(formulas=[])
assert await agent._verify_statements(Folio(page=0, text="text")) == StatementsResult(statements=[])
assert await agent._extract_figures_for_page(Folio(page=0, text="text")) == []
async def _value() -> str:
return "value"
def test_fallback_summary_covers_clean_error_warning_and_unauditable_cases() -> None:
assert "No mathematical errors" in MathAuditorAgent._fallback_summary(0, 0, [0], [])
assert MathAuditorAgent._fallback_summary(1, 0, [0], []) == "Found 1 error."
assert MathAuditorAgent._fallback_summary(2, 1, [0], [1]).startswith("Found 2 errors. Found 1 warning.")
@@ -0,0 +1,49 @@
from decimal import Decimal
import pytest
from stirling.agents.ledger.validators.formula import FormulaEvaluator
@pytest.mark.parametrize(
("expression", "expected"),
[
("-5 + 2", Decimal("-3")),
("10 + -2", Decimal("8")),
("10 - -2", Decimal("12")),
("3 * 4 + 2", Decimal("14")),
("12 / 3 - 1", Decimal("3")),
("", None),
("-", None),
("4 / 0", None),
("1 +", None),
],
)
def test_safe_eval_handles_supported_arithmetic_and_invalid_input(expression: str, expected: Decimal | None) -> None:
assert FormulaEvaluator()._safe_eval(expression) == expected
def test_eval_row_expr_resolves_sum_and_cell_references() -> None:
rows = [["header", "value"], ["x", "2"], ["x", "3"], ["x", "5"]]
assert FormulaEvaluator()._eval_row_expr("sum(col1, 1-3)", rows[1], rows) == Decimal("10")
assert FormulaEvaluator()._eval_row_expr("cell(2, 1) + col1", rows[1], rows) == Decimal("5")
assert FormulaEvaluator()._eval_row_expr("cell(99, 1)", rows[1], rows) is None
assert FormulaEvaluator()._eval_row_expr("col9", rows[1], rows) is None
def test_formula_evaluator_skips_invalid_scopes_and_references() -> None:
evaluator = FormulaEvaluator()
table = "A,B,C\n1,2,3\n4,5,6"
assert evaluator.evaluate(0, "only one row", "col1 = col2", "each_row", "x") == []
assert evaluator.evaluate(0, table, "broken", "each_row", "x") == []
assert evaluator.evaluate(0, table, "bad = col1", "each_row", "x") == []
assert evaluator.evaluate(0, table, "col1 = col2", "each_row", "x", row_range=[99]) == []
assert evaluator.evaluate(0, table, "col1 = col2", "unknown", "x") == []
assert evaluator.evaluate(0, table, "col1 = col2", "column_total", "x") == []
assert evaluator.evaluate(0, table, "col1 = col2", "column_total", "x", target_row=99) == []
assert evaluator.evaluate(0, table, "col1 = col2", "column_total", "x", target_row=2, target_col=99) == []
assert evaluator.evaluate(0, table, "bad", "single_cell", "x") == []
assert evaluator.evaluate(0, table, "cell(99, 1) = col1", "single_cell", "x") == []
assert evaluator.evaluate(0, table, "cell(1, 99) = col1", "single_cell", "x") == []
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import asyncio
import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from conftest import build_app_settings
from stirling.config import AppSettings
app_module = importlib.import_module("stirling.api.app")
# The watcher test temporarily swaps a module-level callback.
# pyright: reportAttributeAccessIssue=false
@pytest.mark.anyio
async def test_reaper_and_sleep_helpers_handle_stop_timeout_and_errors() -> None:
stop = asyncio.Event()
assert await app_module._sleep_until(stop, 0.001) is False
stop.set()
assert await app_module._sleep_until(stop, 1) is True
documents = SimpleNamespace(reap_expired=AsyncMock(return_value=1))
await app_module._reap(documents)
documents.reap_expired.side_effect = RuntimeError("temporary")
await app_module._reap(documents)
documents.reap_expired.side_effect = asyncio.CancelledError()
with pytest.raises(asyncio.CancelledError):
await app_module._reap(documents)
documents.reap_expired.side_effect = None
documents.reap_expired.return_value = 0
stop.clear()
stop.set()
await app_module._run_expired_doc_reaper(documents, 1, stop)
def test_startup_settings_and_cached_config_fallbacks(monkeypatch: pytest.MonkeyPatch) -> None:
settings = build_app_settings()
fast_api = SimpleNamespace(dependency_overrides={app_module.load_settings: lambda: settings})
assert app_module._load_startup_settings(fast_api) is settings
monkeypatch.setattr(app_module, "load_config", lambda: None)
assert app_module._restore_cached_config(settings)[0] is settings
monkeypatch.setattr(app_module, "load_config", lambda: object())
monkeypatch.setattr(app_module, "resolve_and_apply", lambda *_args: (_ for _ in ()).throw(ValueError("bad")))
assert app_module._restore_cached_config(settings)[0] is settings
def test_cached_config_adoption_ignores_missing_and_bad_cache(monkeypatch: pytest.MonkeyPatch) -> None:
state = SimpleNamespace(config_cache_stamp=None)
fast_api = SimpleNamespace(state=state)
monkeypatch.setattr(app_module, "cache_stamp", lambda: None)
app_module._adopt_cached_config_if_changed(fast_api)
monkeypatch.setattr(app_module, "cache_stamp", lambda: "stamp")
monkeypatch.setattr(app_module, "load_config", lambda: None)
app_module._adopt_cached_config_if_changed(fast_api)
assert state.config_cache_stamp == "stamp"
monkeypatch.setattr(app_module, "load_config", lambda: object())
monkeypatch.setattr(app_module, "apply_to_app", lambda *_args: (_ for _ in ()).throw(ValueError("bad")))
state.config_cache_stamp = None
app_module._adopt_cached_config_if_changed(fast_api)
def test_startup_settings_uses_default_loader(monkeypatch: pytest.MonkeyPatch) -> None:
settings = build_app_settings()
fast_api = SimpleNamespace(dependency_overrides={})
monkeypatch.setattr(app_module, "load_settings", lambda: settings)
assert app_module._load_startup_settings(fast_api) is settings
@pytest.mark.anyio
async def test_config_watcher_continues_after_iteration_error() -> None:
stop = asyncio.Event()
calls = 0
def adopt(_api: object) -> None:
nonlocal calls
calls += 1
stop.set()
raise RuntimeError("temporary")
original = app_module._adopt_cached_config_if_changed
app_module._adopt_cached_config_if_changed = adopt
try:
await app_module._run_config_cache_watcher(SimpleNamespace(), 0.001, stop)
finally:
app_module._adopt_cached_config_if_changed = original
assert calls == 1
@pytest.mark.anyio
async def test_healthcheck_uses_live_settings(app_settings: AppSettings) -> None:
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(settings=app_settings)))
result = await app_module.healthcheck(request)
assert result.status == "ok"
assert result.smart_model == app_settings.smart_model_name
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
from typing import Self
import pytest
from stirling.contracts.documents import PageRange
from stirling.documents.pgvector_store import PgVectorStore
from stirling.documents.store import Document, StoredPage
from stirling.models import OwnerId, PrincipalId
# Database tests use an in-memory async connection/pool double.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
class FakeCursor:
def __init__(self, *, owner: str | None = "owner-1") -> None:
self.rowcount = 2
self.owner = owner
self.rows: list[tuple[object, ...]] = [
("chunk-1", "text", {"source": "a"}, 0.9),
(1, "page", 4),
]
self.executed: list[tuple[str, object]] = []
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(self, query: str, params: object = ()) -> None:
self.executed.append((query, params))
async def executemany(self, query: str, params: object) -> None:
self.executed.append((query, params))
async def fetchone(self) -> tuple[str] | None:
return (self.owner,) if self.owner else None
async def fetchall(self) -> list[tuple[object, ...]]:
query = self.executed[-1][0]
if "SELECT id, text" in query:
return [("chunk-1", "text", {"source": "a"}, 0.9)]
if "page_number, text" in query:
return [(1, "page", 4)]
return [("docs",)]
class FakeConnection:
def __init__(self, cursor: FakeCursor) -> None:
self.cursor_value = cursor
self.commits = 0
def cursor(self) -> FakeCursor:
return self.cursor_value
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def commit(self) -> None:
self.commits += 1
class FakePool:
def __init__(self, cursor: FakeCursor) -> None:
self.connection_value = FakeConnection(cursor)
self.closed = False
def connection(self) -> FakeConnection:
return self.connection_value
async def close(self) -> None:
self.closed = True
def store_with_cursor(cursor: FakeCursor | None = None) -> tuple[PgVectorStore, FakePool]:
cursor = cursor or FakeCursor()
pool = FakePool(cursor)
store = object.__new__(PgVectorStore)
store._initialized = True
object.__setattr__(store, "_pool", pool)
return store, pool
def test_pgvector_requires_dsn() -> None:
with pytest.raises(ValueError, match="non-empty DSN"):
PgVectorStore("", 1, 1)
@pytest.mark.anyio
async def test_pgvector_write_acl_and_lifecycle_paths() -> None:
store, pool = store_with_cursor()
owner = OwnerId("owner-1")
await store.ensure_collection("docs", "source.pdf", owner, None)
assert await store.purge_owner(owner) == 2
assert await store.reap_expired() == 2
assert await store.delete_collection("docs", owner) is True
await store.add_documents("docs", [Document("id", "text", {"k": "v"})], [[0.1, 0.2]], owner)
await store.add_pages("docs", [StoredPage(1, "page", 4)], owner)
await store.grant_read("docs", owner, [PrincipalId("user-1"), PrincipalId("user-2")])
await store.revoke("docs", owner, PrincipalId("user-1"))
await store.close()
assert pool.closed
assert pool.connection_value.commits == 8
@pytest.mark.anyio
async def test_pgvector_acl_gated_reads_and_empty_inputs() -> None:
store, _ = store_with_cursor()
owner = OwnerId("owner-1")
assert await store.add_documents("docs", [], [], owner) is None
with pytest.raises(ValueError, match="documents"):
await store.add_documents("docs", [Document("id", "text")], [], owner)
assert await store.grant_read("docs", owner, []) is None
assert await store.search("docs", [0.1], 5, []) == []
assert await store.read_pages("docs", None, []) == []
assert await store.has_collection("docs", []) is False
assert await store.list_collections([]) == []
principal = PrincipalId("user-1")
assert (await store.search("docs", [0.1], 5, [principal]))[0].document.id == "chunk-1"
assert (await store.read_pages("docs", PageRange(start=1, end=1), [principal]))[0].page_number == 1
assert await store.has_collection("docs", [principal]) is True
assert await store.list_collections([principal]) == ["docs"]
@pytest.mark.anyio
async def test_pgvector_denies_reads_without_acl_owner() -> None:
store, _ = store_with_cursor(FakeCursor(owner=None))
principal = PrincipalId("user-1")
assert await store.search("docs", [0.1], 5, [principal]) == []
assert await store.read_pages("docs", None, [principal]) == []
assert await store.has_collection("docs", [principal]) is False
@pytest.mark.anyio
async def test_pgvector_bootstraps_schema_without_connecting_to_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
cursor = FakeCursor()
connection = FakeConnection(cursor)
async def connect(_dsn: str) -> FakeConnection:
return connection
monkeypatch.setattr("stirling.documents.pgvector_store.psycopg.AsyncConnection.connect", connect)
store, _ = store_with_cursor(cursor)
store._dsn = "postgresql://test"
await store._bootstrap_schema()
assert connection.commits == 1
assert len(cursor.executed) >= 9
@@ -0,0 +1,209 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from stirling.agents import _page_text
from stirling.agents.execution import ExecutionPlanningAgent
from stirling.agents.ledger.validators import _parsing
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.user_spec import UserSpecAgent, UserSpecMetadata
from stirling.api import agent_capabilities, dependencies
from stirling.api.routes.agent_capabilities import get_capabilities
from stirling.config import AppSettings
from stirling.contracts import (
AiFile,
ExtractedFileText,
ExtractedTextArtifact,
OrchestratorRequest,
PdfTextSelection,
)
from stirling.contracts.agent_drafts import AgentDraft, AgentDraftRequest, AgentRevisionRequest
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.documents.sqlite_vec_store import SqliteVecStore, _to_sqlite_utc
from stirling.documents.store import Document, StoredPage
from stirling.models import FileId, OwnerId, PrincipalId
from stirling.services import runtime as runtime_module
# Several helpers are deliberately exercised with lightweight runtime fakes.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
def test_page_text_helpers_cover_empty_and_populated_inputs() -> None:
empty = [ExtractedFileText(file_name="empty.pdf", pages=[PdfTextSelection(text=" ")])]
populated = [ExtractedFileText(file_name="a.pdf", pages=[PdfTextSelection(page_number=2, text="hello")])]
request = OrchestratorRequest(
user_message="read",
files=[AiFile(id=FileId("id"), name="a.pdf")],
artifacts=[ExtractedTextArtifact(files=populated)],
)
assert not _page_text.has_page_text(empty)
assert _page_text.format_page_text(empty) == "None"
assert _page_text.format_page_text(empty, empty="missing") == "missing"
assert _page_text.has_page_text(populated)
assert _page_text.format_page_text(populated) == "[File: a.pdf, Page 2]\nhello"
assert _page_text.get_extracted_text_artifact(request) is request.artifacts[0]
assert _page_text.get_extracted_text_artifact(OrchestratorRequest(user_message="none")) is None
def test_parsing_helpers_handle_currency_negatives_and_empty_rows() -> None:
assert _parsing.to_decimal(" € 1,234.50 ") == 1234.50
assert _parsing.to_decimal("(12.5)") == -12.5
assert _parsing.to_decimal("n/a") is None
assert _parsing.to_decimal("not a number") is None
assert _parsing.parse_csv("a,b\n,\n1,2") == [["a", "b"], ["1", "2"]]
@pytest.mark.anyio
async def test_execution_planning_returns_explicit_not_implemented_response() -> None:
request = SimpleNamespace(current_step_index=4)
result = await ExecutionPlanningAgent(None).next_action(request)
assert "step 4" in result.reason
def test_agent_capability_manifest_is_schema_derived() -> None:
payload = agent_capabilities.manifest_payload()
assert payload["version"] == 1
assert len(payload["capabilities"]) == len(agent_capabilities.EXPOSED_CAPABILITIES)
assert all(item["input_schema"] for item in payload["capabilities"])
assert get_capabilities() == payload
def test_dependency_getters_read_app_state() -> None:
state = SimpleNamespace(
runtime=SimpleNamespace(documents="documents"),
orchestrator_agent="orchestrator",
pdf_edit_agent="edit",
pdf_question_agent="question",
user_spec_agent="user-spec",
execution_planning_agent="execution",
math_auditor_agent="math",
pdf_comment_agent="comment",
document_classifier_agent="classifier",
)
request = SimpleNamespace(app=SimpleNamespace(state=state))
assert dependencies.get_runtime(request) is state.runtime
assert dependencies.get_orchestrator_agent(request) == "orchestrator"
assert dependencies.get_pdf_edit_agent(request) == "edit"
assert dependencies.get_pdf_question_agent(request) == "question"
assert dependencies.get_user_spec_agent(request) == "user-spec"
assert dependencies.get_execution_planning_agent(request) == "execution"
assert dependencies.get_document_service(request) == "documents"
assert dependencies.get_math_auditor_agent(request) == "math"
assert dependencies.get_pdf_comment_agent(request) == "comment"
assert dependencies.get_document_classifier_agent(request) == "classifier"
@pytest.mark.anyio
async def test_math_intent_classifier_handles_empty_and_model_result() -> None:
classifier = object.__new__(MathIntentClassifier)
async def run(_message: str) -> SimpleNamespace:
return SimpleNamespace(output=SimpleNamespace(is_math=True))
classifier._agent = SimpleNamespace(run=run)
assert await classifier.classify("") is False
assert await classifier.classify("check totals") is True
assert extract_math_verdict(OrchestratorRequest(user_message="none")) is None
@pytest.mark.anyio
async def test_user_spec_draft_revise_and_prompt_paths() -> None:
agent = object.__new__(UserSpecAgent)
plan = EditPlanResponse(summary="plan", rationale=None, steps=[])
agent._build_edit_plan = AsyncMock(return_value=plan)
agent.agent = SimpleNamespace(
run=AsyncMock(
return_value=SimpleNamespace(
output=UserSpecMetadata(name="name", description="description", objective="objective")
)
)
)
draft_request = AgentDraftRequest(user_message="draft this")
drafted = await agent.draft(draft_request)
assert drafted.draft.name == "name"
assert "plan" in agent._build_draft_prompt(draft_request, plan)
revision_request = AgentRevisionRequest(
user_message="revise this",
current_draft=AgentDraft(name="old", description="old", objective="old", steps=[]),
)
revised = await agent.revise(revision_request)
assert revised.draft.objective == "objective"
assert "Current draft" in agent._build_revision_prompt(revision_request, plan)
assert (await agent.orchestrate(OrchestratorRequest(user_message="orchestrate"))).draft.name == "name"
agent._build_edit_plan = AsyncMock(return_value=SimpleNamespace(outcome="cannot_do"))
assert await agent.draft(draft_request) == agent._build_edit_plan.return_value
def test_runtime_model_and_settings_helpers(app_settings: AppSettings) -> None:
assert runtime_module.build_model_settings(None) == {}
assert runtime_module.build_model_settings(42) == {"max_tokens": 42}
runtime_module.validate_structured_output_support(
SimpleNamespace(profile=SimpleNamespace(supports_json_schema_output=False)), "test"
)
with pytest.raises(ValueError, match="structured outputs"):
runtime_module.validate_structured_output_support(
SimpleNamespace(profile=SimpleNamespace(supports_json_schema_output=False)), "model"
)
assert runtime_module._build_model("test") is not None
assert runtime_module._build_model("model", provider="openai", api_key="key") is not None
assert runtime_module._build_model("model", provider="ollama", base_url="http://localhost") is not None
assert runtime_module._build_model("model", provider="anthropic", api_key="key") is not None
with pytest.raises(ValueError, match="Unsupported model provider"):
runtime_module._build_model("model", provider="unknown")
assert runtime_module._build_document_store(app_settings) is not None
def test_sqlite_store_pure_helpers_and_empty_read_paths() -> None:
from datetime import UTC, datetime
assert _to_sqlite_utc(None) is None
assert _to_sqlite_utc(datetime(2026, 1, 1, 12, tzinfo=UTC)) == "2026-01-01 12:00:00"
assert SqliteVecStore._sanitize_table_name("my-doc", "owner/id") == "vec_owner_id_my_doc"
assert SqliteVecStore._normalize([0.0, 0.0]) == [0.0, 0.0]
assert SqliteVecStore._normalize([3.0, 4.0]) == [0.6, 0.8]
store = SqliteVecStore.ephemeral()
try:
assert store._sync_search("missing", [1.0], 1, []) == []
assert store._sync_read_pages("missing", None, []) == []
assert store._sync_has_collection("missing", []) is False
assert store._sync_list_collections([]) == []
finally:
store._sync_close()
def test_settings_logging_configures_file_and_http_debug(tmp_path: Path) -> None:
from stirling.config.settings import _configure_logging
_configure_logging("not-a-level", str(tmp_path / "engine.log"), True)
_configure_logging("INFO", "", False)
@pytest.mark.anyio
async def test_sqlite_store_file_and_acl_paths(tmp_path: Path) -> None:
store = SqliteVecStore(tmp_path / "docs.db")
owner = OwnerId("owner")
principal = PrincipalId("user")
try:
await store.ensure_collection("docs", "source", owner, None)
await store.add_documents("docs", [Document("id", "hello")], [[1.0, 0.0]], owner)
await store.add_documents("docs", [Document("id", "updated")], [[1.0, 0.0]], owner)
await store.add_pages("docs", [StoredPage(1, "hello", 5)], owner)
await store.grant_read("docs", owner, [principal])
assert (await store.search("docs", [1.0, 0.0], 1, [principal]))[0].document.text == "updated"
assert (await store.read_pages("docs", None, [principal]))[0].text == "hello"
assert await store.has_collection("docs", [principal])
assert await store.list_collections([principal]) == ["docs"]
await store.revoke("docs", owner, principal)
assert not await store.has_collection("docs", [principal])
finally:
await store.close()
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from conftest import build_app_settings
from stirling.config import DocumentsBackend
from stirling.services import runtime as runtime_module
# Provider construction tests use minimal model/profile fakes.
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false
def test_runtime_provider_and_store_branches(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
settings = build_app_settings().model_copy(
update={
"documents_backend": DocumentsBackend.PGVECTOR,
"documents_pgvector_dsn": "postgresql://example",
}
)
store = runtime_module._build_document_store(settings)
assert store is not None
assert runtime_module._build_model("model", provider="openai") is not None
assert runtime_module._build_model("model", provider="custom", base_url="http://localhost") is not None
assert runtime_module._build_model("anthropic:model") is not None
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
assert runtime_module._anthropic_provider() is not None
assert runtime_module._build_anthropic_http_client().timeout.read == 300.0
def test_runtime_document_builder_reuses_supplied_embedder(monkeypatch: pytest.MonkeyPatch) -> None:
settings = build_app_settings()
embedder = object()
monkeypatch.setattr(runtime_module, "_build_document_store", lambda _settings: object())
service = runtime_module._build_documents(settings, embedder=embedder)
assert service is not None
def test_runtime_validation_accepts_supported_model() -> None:
model = SimpleNamespace(profile=SimpleNamespace(supports_json_schema_output=True))
runtime_module.validate_structured_output_support(model, "model")
@pytest.mark.anyio
async def test_openai_transport_normalizes_null_assistant_content(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[httpx.Request] = []
async def base_request(_transport: httpx.AsyncHTTPTransport, request: httpx.Request) -> httpx.Request:
seen.append(request)
return request
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", base_request)
transport = runtime_module._NullContentCoercingTransport()
request = httpx.Request(
"POST",
"http://localhost",
json={"messages": [{"role": "assistant", "content": None}]},
)
await transport.handle_async_request(request)
assert seen
assert b'"content": ""' in seen[0].content
@@ -0,0 +1,97 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from pydantic import BaseModel
from stirling.contracts.progress import ProgressEvent, WholeDocReadDone
from stirling.documents import embedder
from stirling.logging import Pretty
from stirling.services import progress
class DemoModel(BaseModel):
value: int
def test_pretty_formats_models_and_non_models() -> None:
assert '"value": 3' in str(Pretty(DemoModel(value=3)))
assert '"value": 3' in str(Pretty({"value": 3}))
assert "2026" in str(Pretty({"date": object()})) or "object" in str(Pretty({"date": object()}))
@pytest.mark.parametrize(
("provider", "api_key", "base_url"),
[(None, None, None), ("voyageai", "key", None), ("openai", "key", None), ("ollama", None, "http://localhost")],
)
def test_build_embedder_supports_configured_provider_paths(
provider: str | None, api_key: str | None, base_url: str | None
) -> None:
result = embedder._build_embedder("test-model", provider=provider, api_key=api_key, base_url=base_url)
assert result is not None
def test_build_embedder_rejects_unknown_provider() -> None:
with pytest.raises(ValueError, match="Unsupported"):
embedder._build_embedder("test-model", provider="unknown", api_key="key")
@pytest.mark.anyio
async def test_embedding_service_batches_queries_and_prepares_documents(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeEmbedder:
async def embed_query(self, _text: str) -> SimpleNamespace:
return SimpleNamespace(embeddings=[[1, 2]])
async def embed_documents(self, texts: list[str]) -> SimpleNamespace:
return SimpleNamespace(embeddings=[[float(len(text))] for text in texts])
service = embedder.EmbeddingService.__new__(embedder.EmbeddingService)
object.__setattr__(service, "_embedder", FakeEmbedder())
service._chunk_size = 3
service._chunk_overlap = 0
service._embed_batch_size = 2
assert await service.embed_query("query") == [1, 2]
assert await service.embed_documents([]) == []
assert await service.embed_documents(["a", "bb", "ccc"]) == [[1.0], [2.0], [3.0]]
documents = service.chunk_and_prepare("abcdef", "source.pdf", {"kind": "test"})
assert documents[0].metadata == {"kind": "test", "source": "source.pdf", "chunk_index": "0"}
@pytest.mark.anyio
async def test_progress_emitter_is_optional_and_failures_are_swallowed() -> None:
event = WholeDocReadDone(completed=1, slices=1, duration_seconds=0.1)
await progress.emit_progress(event)
seen: list[ProgressEvent] = []
async def emit(value: ProgressEvent) -> None:
seen.append(value)
token = progress.set_progress_emitter(emit)
try:
await progress.emit_progress(event)
finally:
progress.reset_progress_emitter(token)
assert seen == [event]
async def fail(_value: ProgressEvent) -> None:
raise RuntimeError("ignored")
token = progress.set_progress_emitter(fail)
try:
await progress.emit_progress(event)
finally:
progress.reset_progress_emitter(token)
async def cancel(_value: ProgressEvent) -> None:
raise asyncio.CancelledError
token = progress.set_progress_emitter(cancel)
try:
with pytest.raises(asyncio.CancelledError):
await progress.emit_progress(event)
finally:
progress.reset_progress_emitter(token)
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from stirling.models import UserId
from stirling.services import tracking
def test_require_current_user_id_fails_closed_and_returns_context_value() -> None:
with pytest.raises(RuntimeError, match="X-User-Id"):
tracking.require_current_user_id()
token = tracking.current_user_id.set(UserId("user-123"))
try:
assert tracking.require_current_user_id() == "user-123"
finally:
tracking.current_user_id.reset(token)
def test_lru_set_refreshes_existing_entries_and_evicts_oldest() -> None:
values = tracking.LRUSet(max_size=2)
values.add("a")
values.add("b")
values.add("c")
assert "a" not in values
assert "b" in values
assert "c" in values
@pytest.mark.parametrize(
("value", "expected"),
[(None, None), ("not-json", None), (json.dumps({"ok": True}), {"ok": True})],
)
def test_parse_json_attr_handles_missing_invalid_and_valid_values(value: object, expected: object) -> None:
assert tracking._parse_json_attr({"key": value}, "key") == expected
def test_transform_output_choices_converts_tool_calls_and_preserves_plain_choices() -> None:
choices = [
{"role": "assistant", "parts": [{"type": "tool_call", "id": "call-1", "name": "search"}]},
{"role": "assistant", "content": "already plain"},
"not-a-choice",
]
transformed = tracking._transform_output_choices(choices)
assert transformed[0]["content"] == [{"type": "tool_call", "id": "call-1", "name": "search"}]
assert transformed[0]["tool_calls"] == [{"type": "function", "id": "call-1", "function": {"name": "search"}}]
assert transformed[1] == choices[1]
assert transformed[2] == "not-a-choice"
def test_extract_user_message_returns_last_user_text() -> None:
attrs = {
tracking.GEN_AI_INPUT_MESSAGES: json.dumps(
[
{"role": "user", "parts": [{"type": "text", "content": "first"}]},
{"role": "assistant", "parts": [{"type": "text", "content": "answer"}]},
{"role": "user", "parts": [{"type": "text", "content": "last"}]},
]
)
}
assert tracking._extract_user_message(attrs) == "last"
assert tracking._extract_user_message({}) == ""
def test_processor_property_helpers_add_optional_values() -> None:
properties: dict[str, object] = {}
attrs = {
tracking.GEN_AI_INPUT_MESSAGES: json.dumps([{"role": "user"}]),
tracking.GEN_AI_OUTPUT_MESSAGES: json.dumps([{"role": "assistant", "parts": [{"type": "text", "content": "done"}]}]),
tracking.GEN_AI_REQUEST_TEMPERATURE: 0.2,
tracking.GEN_AI_REQUEST_MAX_TOKENS: 100,
tracking.GEN_AI_TOOL_DEFINITIONS: json.dumps([{"name": "search"}]),
tracking.SERVER_ADDRESS: "llm.example",
tracking.SERVER_PORT: 443,
}
tracking.PostHogSpanProcessor._add_message_properties(properties, attrs)
tracking.PostHogSpanProcessor._add_model_parameters(properties, attrs)
tracking.PostHogSpanProcessor._add_tool_definitions(properties, attrs)
tracking.PostHogSpanProcessor._add_base_url(properties, attrs)
assert properties["$ai_input"] == [{"role": "user"}]
output_choices = properties["$ai_output_choices"]
assert isinstance(output_choices, list)
assert isinstance(output_choices[0], dict)
assert output_choices[0]["content"] == [{"type": "text", "content": "done"}]
assert properties["$ai_model_parameters"] == {"temperature": 0.2, "max_tokens": 100}
assert properties["$ai_tools"] == [{"name": "search"}]
assert properties["$ai_base_url"] == "llm.example:443"
def test_processor_emits_generation_and_one_trace_event_per_trace() -> None:
class Client:
def __init__(self) -> None:
self.events: list[dict[str, object]] = []
def capture(self, **event: object) -> None:
self.events.append(event)
client = Client()
processor = tracking.PostHogSpanProcessor(client) # type: ignore[arg-type]
context = SimpleNamespace(trace_id=1, span_id=2)
parent = SimpleNamespace(span_id=3)
span = SimpleNamespace(
attributes={
tracking.GEN_AI_OPERATION_NAME: tracking.GenAiOperationNameValues.CHAT.value,
tracking.GEN_AI_SYSTEM: "provider",
tracking.GEN_AI_RESPONSE_MODEL: "model",
tracking.GEN_AI_INPUT_MESSAGES: json.dumps([{"role": "user", "parts": [{"type": "text", "content": "question"}]}]),
},
context=context,
parent=parent,
start_time=1,
end_time=1_000_000_001,
)
token = tracking.current_user_id.set(UserId("user-123"))
try:
processor.on_end(span) # type: ignore[arg-type]
processor.on_end(span) # type: ignore[arg-type]
finally:
tracking.current_user_id.reset(token)
assert [event["event"] for event in client.events] == ["$ai_trace", "$ai_generation", "$ai_generation"]
assert client.events[0]["distinct_id"] == "user-123"
properties = client.events[0]["properties"]
assert isinstance(properties, dict)
assert properties["$ai_trace_name"] == "question"
def test_processor_ignores_non_chat_spans_and_flushes_client() -> None:
class Client:
def __init__(self) -> None:
self.flushed = False
self.shutdown_called = False
def flush(self) -> None:
self.flushed = True
def shutdown(self) -> None:
self.shutdown_called = True
client = Client()
processor = tracking.PostHogSpanProcessor(client) # type: ignore[arg-type]
span = SimpleNamespace(attributes={tracking.GEN_AI_OPERATION_NAME: "embedding"})
processor.on_end(span) # type: ignore[arg-type]
assert processor.force_flush() is True
processor.shutdown()
assert client.flushed
assert client.shutdown_called
+1 -3
View File
@@ -32,9 +32,7 @@ class StubUserSpecAgent(UserSpecAgent):
],
)
async def _build_edit_plan(
self, user_message: str, conversation_history: list[ConversationMessage]
) -> EditPlanResponse:
async def _build_edit_plan(self, user_message: str, conversation_history: list[ConversationMessage]) -> EditPlanResponse:
return self.edit_plan
async def _run_draft_agent(self, request: AgentDraftRequest, edit_plan: EditPlanResponse) -> AgentDraft:
+156 -120
View File
@@ -260,30 +260,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.67"
version = "1.43.68"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/ef5de9b55523bc2141d072fbe6614627088e3e6f97b47850216e99de6a1c/boto3-1.43.67.tar.gz", hash = "sha256:75fe983b70d39cfdc274dc51f9bb02b8a0a104bdad4fa073c1af85a35e707c91", size = 112665, upload-time = "2026-08-07T19:30:20.418Z" }
sdist = { url = "https://files.pythonhosted.org/packages/35/40/95db6388539e6194b7d8863e4228263e7a778fff0164b17d0e4530d44ce2/boto3-1.43.68.tar.gz", hash = "sha256:4be7531c45fbf8eb145ecd0f385c7b8f9d66ba2fd0c256522717260e67fca89d", size = 112664, upload-time = "2026-08-10T19:22:07.864Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/0f/b41f8968452dc6bf3b83f15f5e9f76a27fd93e246906aeb2e0555f494375/boto3-1.43.67-py3-none-any.whl", hash = "sha256:082cf9df068168cb44028a1703822374c1eb7e48fa49470d7e8a76f0c977d0bc", size = 140025, upload-time = "2026-08-07T19:30:18.49Z" },
{ url = "https://files.pythonhosted.org/packages/96/bd/36dd3f5718160ea7f8ef5a81b461cfb9d0f0b289bb31ed416ae6a1cbfee3/boto3-1.43.68-py3-none-any.whl", hash = "sha256:4be071482312d05ea345ae8a2ea307637d3e2f4fdce44b8ebeb0192929aa2644", size = 140027, upload-time = "2026-08-10T19:22:05.592Z" },
]
[[package]]
name = "botocore"
version = "1.43.67"
version = "1.43.68"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/1c/3a75deae60e36bd0ee5c27d040384756b2ea1c90bd7c8c9658335a18b4f5/botocore-1.43.67.tar.gz", hash = "sha256:6fe5cfa0c8676ba809efe505b618ec00f30d1af2d014bf316a7aa4ee86accb20", size = 15889514, upload-time = "2026-08-07T19:30:15.638Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/dc/ff7e35ecb25e2584e22ce8d5a13c5f991687597fd77d7cc388f2e4768cf3/botocore-1.43.68.tar.gz", hash = "sha256:a6c7ac88724c96f2ad5a7657f87d545d8218a74a1b219ba4397e3d37481941cd", size = 15894825, upload-time = "2026-08-10T19:22:02.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/be/38af8e96f3d200c9d34eab8e9f69a4468388ed6030ea04ff012974e5af5a/botocore-1.43.67-py3-none-any.whl", hash = "sha256:48ab8e9fac26fbc2a700d57010251003e6a5f731cf74d8540fb796bc8f3fc0ef", size = 15575924, upload-time = "2026-08-07T19:30:12.116Z" },
{ url = "https://files.pythonhosted.org/packages/32/48/18dc095a908da94e2389d8e4127f4438b185dfd2dd9190fe6f946b9f41ac/botocore-1.43.68-py3-none-any.whl", hash = "sha256:9985c6eb9b7896f88bd89c07d543accd6985ea772af9d796087012ad40f78420", size = 15579433, upload-time = "2026-08-10T19:21:58.157Z" },
]
[[package]]
@@ -449,6 +449,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
{ url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
{ url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
{ url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
{ url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
{ url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
{ url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
{ url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
{ url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
{ url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
]
[[package]]
name = "cryptography"
version = "50.0.0"
@@ -534,7 +558,7 @@ wheels = [
[[package]]
name = "datamodel-code-generator"
version = "0.64.0"
version = "0.72.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
@@ -546,9 +570,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" }
sdist = { url = "https://files.pythonhosted.org/packages/14/4b/4652f0bb085a564982c3e515a0b02bcee21765ddac967bcf152a70d2af14/datamodel_code_generator-0.72.3.tar.gz", hash = "sha256:a20160de09b76d4a293ccba5a9ee5c341b5365890237ac1ff812f420e4c09f3b", size = 1965678, upload-time = "2026-08-10T18:58:41.636Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" },
{ url = "https://files.pythonhosted.org/packages/84/fd/9d8a0594aedcc6ee8133b21dd3543d6021842e013058803a69918720d0aa/datamodel_code_generator-0.72.3-py3-none-any.whl", hash = "sha256:4536f6dd12dd86c9c7a78b16ef83ae9aa7a9cbdb8bef8a3b63baa4ad9d3d3829", size = 554226, upload-time = "2026-08-10T18:58:40.087Z" },
]
[package.optional-dependencies]
@@ -638,6 +662,8 @@ cucumber = [
engine = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "openai" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
{ name = "posthog" },
@@ -648,6 +674,7 @@ engine = [
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "sqlite-vec" },
{ name = "starlette" },
{ name = "uvicorn" },
]
engine-dev = [
@@ -655,6 +682,7 @@ engine-dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "referencing" },
{ name = "ruff" },
]
@@ -697,7 +725,9 @@ cucumber = [
engine = [
{ name = "cryptography", specifier = ">=50.0.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "openai", specifier = ">=2.0.0,<3.0.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.1,<1.44.0" },
{ name = "pgvector", specifier = ">=0.5.0" },
{ name = "posthog", specifier = ">=7.38.3" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.4" },
@@ -707,20 +737,22 @@ engine = [
{ name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "starlette", specifier = ">=1.3.1" },
{ name = "uvicorn", specifier = ">=0.52.1" },
]
engine-dev = [
{ name = "anyio", specifier = ">=4.14.2" },
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = "==0.64.0" },
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.72.0" },
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "referencing", specifier = ">=0.37.0" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "ruff", specifier = ">=0.16.1" },
]
pre-commit = [
{ name = "codespell", specifier = "==2.4.2" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "codespell", specifier = ">=2.4.2" },
{ name = "ruff", specifier = ">=0.16.1" },
{ name = "tomli-w", specifier = ">=1.2.0" },
]
tools = [
{ name = "deep-translator", specifier = ">=1.11.4" },
@@ -802,19 +834,19 @@ wheels = [
[[package]]
name = "fastmcp"
version = "3.4.6"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/e2c78e26233cd8a416b21513e1925435d54c008a0ec467dbdaa80369daf7/fastmcp-3.4.6.tar.gz", hash = "sha256:2287938da8364ad7071bec2d2393af6ae10fd4e836f06f506569f1456cc87eb4", size = 28808130, upload-time = "2026-08-05T14:54:42.177Z" }
sdist = { url = "https://files.pythonhosted.org/packages/62/dd/fd444d94ae7afdaf5b6dd168799d34023f576b405872d6a27d5686a9d1f4/fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a", size = 28808982, upload-time = "2026-08-10T21:17:55.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/a5/c02275db111892388972edbb05fbcbfdf1e83cbd1fd03356b3a49b93f839/fastmcp-3.4.6-py3-none-any.whl", hash = "sha256:2a29967be9f68cdd1b4cefb413ede74f83adefd923919a37aaac3611eccdd749", size = 8017, upload-time = "2026-08-05T14:54:38.473Z" },
{ url = "https://files.pythonhosted.org/packages/ac/14/6d950459cc831fa17fe2d1797926b6eb2d2f2af50f830e62d0c098cc1ec8/fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56", size = 8016, upload-time = "2026-08-10T21:17:51.391Z" },
]
[[package]]
name = "fastmcp-slim"
version = "3.4.6"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
@@ -824,9 +856,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/b6/b5b9e81e67a3f39534881d2af6aa3fbd2dc367eaa070c3c932770a0c062f/fastmcp_slim-3.4.6.tar.gz", hash = "sha256:6a1e6e42c697ba90abcb1be617a26947d07316f13c6fa138ae7b0de24558e32c", size = 594167, upload-time = "2026-08-05T14:54:15.924Z" }
sdist = { url = "https://files.pythonhosted.org/packages/12/ac/7924e803368d0758ee4d6b1259066550df78f58f0f9f8bfebd5a123e957d/fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f", size = 594357, upload-time = "2026-08-10T21:17:28.723Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/0b/02c254c46b4323ae5262b76a29af73b50a863efc5739a3454d144d3605ee/fastmcp_slim-3.4.6-py3-none-any.whl", hash = "sha256:3e08e6acb03523a47aa17f4d2ab9943e648a41e20b24084da491a732c46dffdc", size = 769174, upload-time = "2026-08-05T14:54:14.663Z" },
{ url = "https://files.pythonhosted.org/packages/b4/97/e0e53642cd029a9a7635ae9c548f9f2cc995af5914e487b3df795664e4be/fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a", size = 769370, upload-time = "2026-08-10T21:17:27.227Z" },
]
[package.optional-dependencies]
@@ -898,16 +930,16 @@ woff = [
[[package]]
name = "fpdf2"
version = "2.8.7"
version = "2.8.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
{ name = "fonttools" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/bc/8fd4321aed40cadadddc8f311c65b6082346b252bca048f7b476d8f35d72/fpdf2-2.8.8.tar.gz", hash = "sha256:9e94e155e85e8053329a9a1fce8b566fd7a7c5bb79e98a1a3952d379b947c5b9", size = 374689, upload-time = "2026-08-09T23:32:45.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/af012eda9507494f28b99b077423806c43a11573eb6225dd46f19ae2d263/fpdf2-2.8.8-py3-none-any.whl", hash = "sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17", size = 337000, upload-time = "2026-08-09T23:32:43.728Z" },
]
[[package]]
@@ -1218,18 +1250,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "inflect"
version = "7.5.0"
@@ -1635,7 +1655,7 @@ wheels = [
[[package]]
name = "mistralai"
version = "2.9.1"
version = "2.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
@@ -1647,9 +1667,9 @@ dependencies = [
{ name = "python-dateutil" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/e0/9d3e8bcfa73357f997ea297148d16ac243d1a228838334179a9199b713ff/mistralai-2.9.1.tar.gz", hash = "sha256:5b3983c6fddc81b898f1dd5a61a96a59a0e18069c54940cac9b5220dd6b66486", size = 534224, upload-time = "2026-08-04T16:23:45.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/50/d9/bc41311a7bd3f34f3aa508329fbaf675482643db7301efba2c3d7cdcc7c6/mistralai-2.9.2.tar.gz", hash = "sha256:50d98863aea6588d825a962aa3684361d1446d9f6f4530028705dac2799603ed", size = 537750, upload-time = "2026-08-11T07:51:17.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/6e/11d537beb67fd7dc0f1028c4a9f4f8230c6328abf6e2f2c9d0447ea0e4a4/mistralai-2.9.1-py3-none-any.whl", hash = "sha256:6f83177b8c4fdffdd2a054cf9d96fc7c0d7a474e856ecd1f0b7070344128516b", size = 1270991, upload-time = "2026-08-04T16:23:43.001Z" },
{ url = "https://files.pythonhosted.org/packages/35/cf/3e90441960bfea6559cb6af5d1ca3c6ff9d39866a4fe0d19b4e85cc45786/mistralai-2.9.2-py3-none-any.whl", hash = "sha256:9d90b85b25c409e50aabe150350aa680f71e0803c0273d177a0379912f73bc45", size = 1282115, upload-time = "2026-08-11T07:51:16.005Z" },
]
[[package]]
@@ -1807,32 +1827,31 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
{ url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.1"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
{ url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.39.1"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -1843,14 +1862,14 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
{ url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.60b1"
version = "0.64b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
@@ -1858,14 +1877,14 @@ dependencies = [
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0c/cb9fe342de5299c7af24582eb7d788661cc53a1c4b904da92309caaa9417/opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b", size = 35880, upload-time = "2026-06-24T15:18:17.277Z" },
]
[[package]]
name = "opentelemetry-instrumentation-httpx"
version = "0.60b1"
version = "0.64b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
@@ -1874,57 +1893,57 @@ dependencies = [
{ name = "opentelemetry-util-http" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/2893a8781b93894f1e8014904c0342da7e4302de6597c2d5c0cb6c1a552e/opentelemetry_instrumentation_httpx-0.64b0.tar.gz", hash = "sha256:c2cfcd03d3665762860ebd0c28038c6e47fbb48d7942dec31dd75fc634d25c92", size = 23555, upload-time = "2026-06-24T15:19:29.107Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" },
{ url = "https://files.pythonhosted.org/packages/06/29/a20309bd3f5a8051b61ca475e78623410c3b077e3deccae19aa4f8b5b9a2/opentelemetry_instrumentation_httpx-0.64b0-py3-none-any.whl", hash = "sha256:04829e5723941b5ceb0c88b44d63983e226b5c75b2b2e34a57739fdd0e060608", size = 16336, upload-time = "2026-06-24T15:18:41.412Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.39.1"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.39.1"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
{ url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.60b1"
version = "0.64b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" },
]
[[package]]
name = "opentelemetry-util-http"
version = "0.60b1"
version = "0.64b0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/1b/1029a805fd7242f7dfce91633b244c3b14a94d703232878f71e01ce862b1/opentelemetry_util_http-0.64b0.tar.gz", hash = "sha256:8a86a220dbfc56d736f47f1e5c4e7932a21fcf69052312e1bcf166444dc79322", size = 11102, upload-time = "2026-06-24T15:19:48.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c7/5f8ec5b30546f2dc22cd5fc5759bce2ab5be6e89a2e710a405ac9ef64ed3/opentelemetry_util_http-0.64b0-py3-none-any.whl", hash = "sha256:c1e5350d25507c1afcd6076cf9ac062485a0a4f79cd9971366996fd3056bacdb", size = 8204, upload-time = "2026-06-24T15:19:09.02Z" },
]
[[package]]
@@ -2042,11 +2061,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.11.1"
version = "4.11.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" },
{ url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" },
]
[[package]]
@@ -2060,7 +2079,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.38.3"
version = "7.38.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
@@ -2068,9 +2087,9 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/4f/edfbe2aac190ade695693a452bb6d979e8ca5bc54236c28cb796a43ce79d/posthog-7.38.3.tar.gz", hash = "sha256:2a21f13eb48985be58d9533221ec032f579a0f5becf14264a3ea277888af21d1", size = 422163, upload-time = "2026-08-07T18:05:13.739Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/c8/73ad89833953426b150462c3f3ea5ffda917f65e537acf47849ad61765e9/posthog-7.38.4.tar.gz", hash = "sha256:ec8f46255a7c30629e7fec6aef04ce79e157dbfff4e1d8ca0e0612e0abe16a68", size = 422782, upload-time = "2026-08-10T14:02:45.585Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/f4/e5a968a7f843dc31215c694e2edfafa0644e9b9e833588c1f4adfc0175ee/posthog-7.38.3-py3-none-any.whl", hash = "sha256:099aeea324b6c20e352a5bf37a43428fce20c76459d3d27da3b8c8df7814179f", size = 496878, upload-time = "2026-08-07T18:05:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/83/ca/6c312595f4b84d725bdb703796a21f36a4cc3474ce195ebb4a7756e851bb/posthog-7.38.4-py3-none-any.whl", hash = "sha256:1e9d7dd229542528110b516a83de2c6efbd0cd3b82f6c55fd55ac7ff57fd6f72", size = 497481, upload-time = "2026-08-10T14:02:43.802Z" },
]
[[package]]
@@ -2549,6 +2568,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -2791,27 +2824,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.5"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
{ url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
]
[[package]]
@@ -3117,14 +3150,14 @@ wheels = [
[[package]]
name = "typing-inspection"
version = "0.4.2"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
{ url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" },
]
[[package]]
@@ -3138,11 +3171,11 @@ wheels = [
[[package]]
name = "uncalled-for"
version = "0.3.2"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
{ url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" },
]
[[package]]
@@ -3320,21 +3353,33 @@ wheels = [
[[package]]
name = "wrapt"
version = "1.17.3"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" },
{ url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" },
{ url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" },
{ url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" },
{ url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" },
{ url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" },
{ url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" },
{ url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" },
{ url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" },
{ url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" },
{ url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" },
]
[[package]]
@@ -3438,15 +3483,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
]
[[package]]
name = "zipp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]
[[package]]
name = "zopfli"
version = "0.4.3"
+1 -1
View File
@@ -227,7 +227,7 @@ def main() -> None:
print(f"Total image elements: {page_stats.total_image_elements:,}")
print(
f"Page structural bytes (text arrays + images + streams + annotations): "
f"{human_bytes(page_stats.text_struct_bytes + page_stats.image_struct_bytes + page_stats.content_stream_bytes + page_stats.annotations_bytes)}"
f"{human_bytes(page_stats.text_struct_bytes + page_stats.image_struct_bytes + page_stats.content_stream_bytes + page_stats.annotations_bytes)}" # noqa: E501
)
font_stats = summary.fonts
+6 -6
View File
@@ -75,7 +75,7 @@ def parse_unicode_mapping(mapping_path):
print(f"Parsed ToUnicode CMap: {len(gid_to_unicode)} mappings", file=sys.stderr)
return gid_to_unicode
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Failed to parse Unicode mapping: {e}", file=sys.stderr)
return {}
@@ -186,10 +186,10 @@ def wrap_cff_as_otf(input_path, output_path, tounicode_path=None):
min_lsb = min(min_lsb, lsb)
min_rsb = min(min_rsb, rsb)
max_extent = max(max_extent, extent)
except Exception:
except Exception: # noqa: BLE001
pass # Some glyphs may not have outlines
except Exception:
except Exception: # noqa: BLE001
pass # Use defaults
widths[glyph_name] = width
@@ -308,14 +308,14 @@ def wrap_cff_as_otf(input_path, output_path, tounicode_path=None):
unicode_val = int(glyph_name[3:], 16)
if unicode_val not in unicode_to_glyph:
unicode_to_glyph[unicode_val] = glyph_name
except Exception:
except Exception: # noqa: BLE001
pass
elif glyph_name.startswith("u") and len(glyph_name) >= 5:
try:
unicode_val = int(glyph_name[1:], 16)
if unicode_val not in unicode_to_glyph:
unicode_to_glyph[unicode_val] = glyph_name
except Exception:
except Exception: # noqa: BLE001
pass
# === Create cmap table ===
@@ -476,7 +476,7 @@ def wrap_cff_as_otf(input_path, output_path, tounicode_path=None):
return True
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"ERROR: Conversion failed: {str(e)}", file=sys.stderr)
import traceback
+3 -8
View File
@@ -205,7 +205,7 @@ def compare_files(
Returns:
list[tuple[str, int]]: A sorted list of tuples containing language codes and progress percentages
(descending order by percentage). Duplicates are removed.
"""
""" # noqa: E501
reference_entries = load_translation_entries(default_file_path)
ref_keys = set(reference_entries.keys())
num_lines = len(ref_keys)
@@ -235,10 +235,7 @@ def compare_files(
sort_ignore_translation[language] = tomlkit.table()
# Ensure default ignore list if empty
if (
"ignore" not in sort_ignore_translation[language]
or len(sort_ignore_translation[language].get("ignore", [])) < 1
):
if "ignore" not in sort_ignore_translation[language] or len(sort_ignore_translation[language].get("ignore", [])) < 1:
sort_ignore_translation[language]["ignore"] = tomlkit.array(["language.direction"])
# Clean up ignore list to only include keys present in reference
@@ -307,9 +304,7 @@ def main() -> None:
--show-percentage: Print only the translation percentage for --lang and exit.
--show-missing-keys: Show the list of missing keys when checking a single language file.
"""
parser = argparse.ArgumentParser(
description="Compare frontend i18n TOML files and optionally update README badges."
)
parser = argparse.ArgumentParser(description="Compare frontend i18n TOML files and optionally update README badges.")
parser.add_argument(
"--lang",
"-l",
+1 -5
View File
@@ -116,11 +116,7 @@ def _classify_backend(package_name: str) -> str | None:
return "saas"
if p.startswith("stirling.software.proprietary"):
return "proprietary"
if (
p.startswith("stirling.software.SPDF")
or p.startswith("stirling.software.common")
or p.startswith("org.apache.pdfbox")
):
if p.startswith("stirling.software.SPDF") or p.startswith("stirling.software.common") or p.startswith("org.apache.pdfbox"):
return "core"
return None
+2 -4
View File
@@ -150,7 +150,7 @@ def download_pdf(
output_dir.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
return url, dest, None
except Exception as exc: # pylint: disable=broad-except
except Exception as exc: # pylint: disable=broad-except # noqa: BLE001
return url, None, str(exc)
@@ -167,9 +167,7 @@ def main() -> None:
failures: list[tuple[str, str]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
future_to_url = {
executor.submit(download_pdf, url, output_dir, args.timeout, args.overwrite): url for url in urls
}
future_to_url = {executor.submit(download_pdf, url, output_dir, args.timeout, args.overwrite): url for url in urls}
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
result_url, path, error = future.result()
+4 -5
View File
@@ -118,7 +118,7 @@ def collect_known_signatures(signatures_dir: Path) -> dict[str, dict]:
for json_file in signatures_dir.rglob("*.json"):
try:
payload = load_signature_file(json_file)
except Exception:
except Exception: # noqa: BLE001
continue
pdf = payload.get("pdf")
for font in payload.get("fonts", []):
@@ -148,8 +148,7 @@ def run_signature_tool(gradle_cmd: str, pdf: Path, output_path: Path, pretty: bo
cmd,
shell=True,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
capture_output=True,
text=True,
)
if completed.returncode != 0:
@@ -202,13 +201,13 @@ def main() -> None:
if signature_path.exists() and not args.force:
try:
payload = load_signature_file(signature_path)
except Exception as exc:
except Exception as exc: # noqa: BLE001
print(f"[WARN] Failed to parse cached signature {signature_path}: {exc}")
payload = None
else:
try:
run_signature_tool(args.gradle_cmd, pdf, signature_path, args.pretty, REPO_ROOT)
except Exception as exc:
except Exception as exc: # noqa: BLE001
print(f"[ERROR] Harvest failed for {pdf}: {exc}", file=sys.stderr)
continue
payload = load_signature_file(signature_path)
+1 -1
View File
@@ -55,7 +55,7 @@ def main():
for pdf in sorted(samples_dir.glob("*.pdf")):
try:
output = run(["pdffonts", str(pdf)])
except Exception as exc:
except Exception as exc: # noqa: BLE001
print(f"Skipping {pdf.name}: {exc}")
continue
for font_name, encoding in parse_pdffonts(output):
+1 -3
View File
@@ -56,9 +56,7 @@ def write_markdown(inventory: dict[str, list[dict]], output: Path, input_dir: Pa
lines: list[str] = []
lines.append("# Type3 Signature Inventory")
lines.append("")
lines.append(
f"_Generated from `{input_dir}`. Run `scripts/summarize_type3_signatures.py` after capturing new samples._"
)
lines.append(f"_Generated from `{input_dir}`. Run `scripts/summarize_type3_signatures.py` after capturing new samples._")
lines.append("")
for alias in sorted(inventory.keys()):
+1 -4
View File
@@ -576,10 +576,7 @@ def sync_en_us(dry_run: bool) -> int:
lines.append(f"[{name}]")
lines.extend(f'{k} = "{v}"' for k, v in kvs)
print(
f"en-US: +{len(added)} key(s) from en-GB, "
f"{len(us_only)} en-US-only key(s) preserved (British->American applied)."
)
print(f"en-US: +{len(added)} key(s) from en-GB, {len(us_only)} en-US-only key(s) preserved (British->American applied).")
for k in added:
print(f" [en-US] + {k}")
if not dry_run:
@@ -28,7 +28,7 @@ class AITranslationHelper:
try:
with open(file_path, "rb") as f:
return tomllib.load(f)
except (FileNotFoundError, Exception) as e:
except (FileNotFoundError, Exception) as e: # noqa: BLE001
print(f"Error loading {file_path}: {e}")
return {}
@@ -52,7 +52,7 @@ class AITranslationHelper:
"target_languages": languages,
"max_entries_per_language": max_entries_per_language,
"instructions": {
"format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}",
"format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}", # noqa: E501
"context": "This is for a PDF manipulation tool. Keep technical terms consistent.",
"placeholders": "Preserve all placeholders: {n}, {total}, {filename}, etc.",
"style": "Keep translations concise and user-friendly",
+5 -7
View File
@@ -10,9 +10,9 @@ import json
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -152,7 +152,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600, model="g
print(f"\n[{i}/{total}] Translating {batch_file}...")
# Always pass API key since it's required
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}'
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}' # noqa: E501
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
@@ -223,7 +223,7 @@ def apply_translations(merged_file, language_code):
"""Apply merged translations to the language file."""
print(f"\n📝 Applying translations to {language_code}...")
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}"
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}" # noqa: E501
if not run_command(cmd):
print("✗ Failed to apply translations")
@@ -352,9 +352,7 @@ Examples:
sys.exit(0)
# Step 2: Translate all batches
translated_files = translate_batches(
batch_files, args.language, api_key, args.timeout, args.model, args.parallel
)
translated_files = translate_batches(batch_files, args.language, api_key, args.timeout, args.model, args.parallel)
if translated_files is None:
sys.exit(1)
@@ -388,7 +386,7 @@ Examples:
except KeyboardInterrupt:
print("\n\n⚠ Translation interrupted by user")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"\n\n✗ Error: {e}")
import traceback
+2 -2
View File
@@ -98,7 +98,7 @@ CRITICAL RULES - MUST FOLLOW EXACTLY:
- Do not remove any part of the original meaning
- Keep the same level of detail
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object.""" # noqa: E501
def _record_usage(self, response) -> None:
"""Accumulate token usage/cost and print a per-batch line."""
@@ -366,7 +366,7 @@ Examples:
if i < len(input_files):
time.sleep(args.delay)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"✗ Failed: {e}")
failed += 1
continue
+2 -2
View File
@@ -79,7 +79,7 @@ def get_language_completion(locales_dir: Path, language: str) -> float | None:
return (translated / total * 100) if total > 0 else 0.0
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not calculate completion for {language}: {e}")
return None
@@ -144,7 +144,7 @@ def translate_language(
except subprocess.TimeoutExpired:
safe_print(f"[{language}] ✗ Timeout exceeded")
return (language, False, "Timeout exceeded")
except Exception as e:
except Exception as e: # noqa: BLE001
safe_print(f"[{language}] ✗ Error: {str(e)}")
return (language, False, str(e))
+2 -2
View File
@@ -38,7 +38,7 @@ class CompactTranslationExtractor:
except FileNotFoundError:
print(f"Error: File not found: {file_path}", file=sys.stderr)
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML file {file_path}: {e}", file=sys.stderr)
sys.exit(1)
@@ -51,7 +51,7 @@ class CompactTranslationExtractor:
with open(self.ignore_file, "rb") as f:
ignore_data = tomllib.load(f)
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
except Exception as e:
except Exception as e: # noqa: BLE001
print(
f"Warning: Could not load ignore file {self.ignore_file}: {e}",
file=sys.stderr,
+1 -1
View File
@@ -28,7 +28,7 @@ class TOMLBeautifier:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML in {file_path}: {e}")
sys.exit(1)
+3 -3
View File
@@ -33,7 +33,7 @@ def get_line_context(file_path, line_num, context_lines=3):
context.append(f"{marker}{i + 1:4d}: {lines[i].rstrip()}")
return "\n".join(context)
except Exception as e:
except Exception as e: # noqa: BLE001
return f"Could not read context: {e}"
@@ -56,7 +56,7 @@ def get_character_context(file_path, char_pos, context_chars=100):
"after": after,
"display": f"{before}[{error_char}]{after}",
}
except Exception:
except Exception: # noqa: BLE001
return None
@@ -90,7 +90,7 @@ def validate_toml_file(file_path):
result["valid"] = True
result["entry_count"] = count_keys(data)
except Exception as e:
except Exception as e: # noqa: BLE001
error_msg = str(e)
result["error"] = error_msg
+4 -7
View File
@@ -31,7 +31,7 @@ class TranslationAnalyzer:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid file {file_path}: {e}")
sys.exit(1)
@@ -46,12 +46,9 @@ class TranslationAnalyzer:
# Convert lists to sets for faster lookup
return {
lang: set(patterns)
for lang, data in ignore_data.items()
for patterns in [data.get("ignore", [])]
if patterns
lang: set(patterns) for lang, data in ignore_data.items() for patterns in [data.get("ignore", [])] if patterns
}
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
return {}
@@ -282,7 +279,7 @@ def main():
print("\nBottom 5 Languages Needing Attention:")
for result in sorted_by_completion[-5:]:
print(
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)"
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)" # noqa: E501
)
+4 -6
View File
@@ -39,7 +39,7 @@ class TranslationMerger:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid file {file_path}: {e}")
sys.exit(1)
@@ -64,7 +64,7 @@ class TranslationMerger:
# Convert to sets for faster lookup
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
return {}
@@ -264,7 +264,7 @@ class TranslationMerger:
self._set_nested_value(target_data, key, translation)
applied_count += 1
except Exception as e:
except Exception as e: # noqa: BLE001
errors.append(f"Error setting {key}: {e}")
if applied_count > 0:
@@ -467,9 +467,7 @@ def main():
# Extract translations from template format or simple dict
if "translations" in translations_data:
translations = {
k: v["translated"] for k, v in translations_data["translations"].items() if v.get("translated")
}
translations = {k: v["translated"] for k, v in translations_data["translations"].items() if v.get("translated")}
else:
translations = translations_data
@@ -37,7 +37,7 @@ def validate_translation_file(file_path: Path) -> tuple[bool, str]:
with open(file_path, "rb") as f:
tomllib.load(f)
return True, "Valid TOML"
except Exception as e:
except Exception as e: # noqa: BLE001
return False, f"Error reading file: {str(e)}"
+3 -3
View File
@@ -74,7 +74,7 @@ def load_json(path: Path) -> dict[str, object]:
try:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
except Exception as exc: # pragma: no cover - fatal configuration error
except Exception as exc: # pragma: no cover - fatal configuration error # noqa: BLE001
print(f"ERROR: Failed to load glyph JSON '{path}': {exc}", file=sys.stderr)
sys.exit(2)
@@ -298,7 +298,7 @@ def build_ttf_glyph(glyph: GlyphSource, max_error: float) -> object | None:
try:
glyph_obj = pen.glyph()
except Exception:
except Exception: # noqa: BLE001
return None
return glyph_obj
@@ -472,7 +472,7 @@ def main() -> None:
units_per_em=args.units_per_em,
cu2qu_error=args.cu2qu_error,
)
except Exception as exc:
except Exception as exc: # noqa: BLE001
print(f"ERROR: Failed to generate fonts: {exc}", file=sys.stderr)
if otf_output.exists():
otf_output.unlink()
+2 -4
View File
@@ -100,14 +100,12 @@ def normalize_source_path(pdf_path: str | None) -> str | None:
try:
source = Path(pdf_path)
rel = source.relative_to(REPO_ROOT)
except Exception:
except Exception: # noqa: BLE001
rel = Path(pdf_path)
return str(rel).replace("\\", "/")
def update_library(
signatures_dir: Path, index_path: Path, apply_changes: bool
) -> tuple[int, int, list[tuple[str, Path]]]:
def update_library(signatures_dir: Path, index_path: Path, apply_changes: bool) -> tuple[int, int, list[tuple[str, Path]]]:
entries = load_json(index_path)
alias_index, signature_index = make_alias_index(entries)
@@ -16,6 +16,6 @@ _parent_steps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../s
if _parent_steps not in sys.path:
sys.path.insert(0, _parent_steps)
from step_definitions import * # noqa: F401, F403
from auth_step_definitions import * # noqa: F401, F403
from enterprise_step_definitions import * # noqa: F401, F403
from auth_step_definitions import * # noqa: E402, F403
from enterprise_step_definitions import * # noqa: E402, F403
from step_definitions import * # noqa: E402, F403
+26 -11
View File
@@ -12,12 +12,25 @@ _REPORT_DIR = os.environ.get("TEST_REPORT_DIR", "")
# @login and @register scenarios work in both modes.
# The "jwt" tag itself is included so that feature-level @jwt tagging is sufficient
# to mark an entire feature as JWT-dependent.
_JWT_DEPENDENT_TAGS = frozenset({
# jwt_auth.feature scenario tags
"me", "refresh", "logout", "role", "token", "mfa", "apikey",
# proprietary/enterprise feature tags (all scenarios in these features need JWT)
"jwt", "user_mgmt", "admin_settings", "audit", "signature", "team",
})
_JWT_DEPENDENT_TAGS = frozenset(
{
# jwt_auth.feature scenario tags
"me",
"refresh",
"logout",
"role",
"token",
"mfa",
"apikey",
# proprietary/enterprise feature tags (all scenarios in these features need JWT)
"jwt",
"user_mgmt",
"admin_settings",
"audit",
"signature",
"team",
}
)
# Tags for scenarios that require the policies feature (policies.enabled=true).
_POLICIES_DEPENDENT_TAGS = frozenset({"policies", "webhook"})
@@ -56,7 +69,9 @@ def _get_docker_log_line_count():
try:
result = subprocess.run(
["docker", "logs", _CONTAINER_NAME],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
return len(result.stdout.splitlines()) + len(result.stderr.splitlines())
except Exception:
@@ -69,7 +84,9 @@ def _capture_docker_logs_window(start_line, scenario_name):
try:
result = subprocess.run(
["docker", "logs", _CONTAINER_NAME],
capture_output=True, text=True, timeout=10,
capture_output=True,
text=True,
timeout=10,
)
all_lines = (result.stdout + result.stderr).splitlines()
window = all_lines[start_line:]
@@ -200,9 +217,7 @@ def after_scenario(context, scenario):
# Remove any temporary files generated during the scenario
for temp_file in os.listdir("."):
if temp_file.startswith("genericNonCustomisableName") or temp_file.startswith(
"temp_image_"
):
if temp_file.startswith("genericNonCustomisableName") or temp_file.startswith("temp_image_"):
try:
os.remove(temp_file)
except Exception:
@@ -11,8 +11,6 @@ Covers:
- User registration
"""
import json as json_module
import requests
from behave import given, then, when
@@ -60,9 +58,7 @@ def _do_login(username, password):
def step_logged_in_as_admin(context):
"""Login as the default admin user and store the JWT token in context."""
response = _do_login(ADMIN_USERNAME, ADMIN_PASSWORD)
assert response.status_code == 200, (
f"Admin login failed (status {response.status_code}): {response.text}"
)
assert response.status_code == 200, f"Admin login failed (status {response.status_code}): {response.text}"
data = response.json()
context.jwt_token = data["session"]["access_token"]
@@ -70,9 +66,7 @@ def step_logged_in_as_admin(context):
@given("I store the JWT token")
def step_store_current_jwt(context):
"""Store the currently held jwt_token into context for later comparison."""
assert hasattr(context, "jwt_token") and context.jwt_token, (
"No JWT token available did you log in first?"
)
assert hasattr(context, "jwt_token") and context.jwt_token, "No JWT token available did you log in first?"
context.original_jwt_token = context.jwt_token
@@ -206,8 +200,6 @@ def step_get_with_empty_auth_header(context, endpoint):
)
@when('I send a GET request to "{endpoint}" with the stored JWT token')
def step_get_with_stored_jwt(context, endpoint):
"""Send GET request using the JWT token currently stored in context."""
@@ -252,9 +244,7 @@ def step_post_with_invalid_jwt(context, endpoint, token_value):
)
@when(
'I send a JSON POST request to "{endpoint}" with JWT authentication and body \'{json_body}\''
)
@when("I send a JSON POST request to \"{endpoint}\" with JWT authentication and body '{json_body}'")
def step_json_post_with_jwt(context, endpoint, json_body):
"""Send JSON POST request using the stored JWT token and a JSON body."""
headers = {
@@ -269,9 +259,7 @@ def step_json_post_with_jwt(context, endpoint, json_body):
)
@when(
'I send a JSON POST request to "{endpoint}" with API key "{api_key}" and body \'{json_body}\''
)
@when('I send a JSON POST request to "{endpoint}" with API key "{api_key}" and body \'{json_body}\'')
def step_json_post_with_api_key(context, endpoint, api_key, json_body):
"""Send JSON POST request using X-API-KEY header and a JSON body."""
headers = {
@@ -333,8 +321,7 @@ def step_status_code_one_of(context, codes):
allowed = [int(c.strip()) for c in codes.split(",")]
actual = context.response.status_code
assert actual in allowed, (
f"Expected status code to be one of {allowed} but got {actual}. "
f"Body: {context.response.text[:500]}"
f"Expected status code to be one of {allowed} but got {actual}. Body: {context.response.text[:500]}"
)
@@ -348,15 +335,11 @@ def step_response_contains_jwt(context):
"""Assert the response has a session.access_token that looks like a JWT."""
data = context.response.json()
assert "session" in data, f"No 'session' key in response: {data}"
assert "access_token" in data["session"], (
f"No 'access_token' in session: {data['session']}"
)
assert "access_token" in data["session"], f"No 'access_token' in session: {data['session']}"
token = data["session"]["access_token"]
assert token, "access_token is empty"
parts = token.split(".")
assert len(parts) == 3, (
f"JWT should have 3 dot-separated parts but got {len(parts)}: {token[:60]}..."
)
assert len(parts) == 3, f"JWT should have 3 dot-separated parts but got {len(parts)}: {token[:60]}..."
@then("the JWT access token should have three dot-separated parts")
@@ -366,9 +349,7 @@ def step_jwt_three_parts(context):
token = data.get("session", {}).get("access_token", "")
assert token, "No access_token found in response"
parts = token.split(".")
assert len(parts) == 3, (
f"JWT must have 3 parts (header.payload.signature) but got {len(parts)}: {token[:60]}"
)
assert len(parts) == 3, f"JWT must have 3 parts (header.payload.signature) but got {len(parts)}: {token[:60]}"
# ---------------------------------------------------------------------------
@@ -376,13 +357,11 @@ def step_jwt_three_parts(context):
# ---------------------------------------------------------------------------
@then("the response JSON should have field \"{field}\"")
@then('the response JSON should have field "{field}"')
def step_json_has_field(context, field):
"""Assert the top-level response JSON contains the specified field."""
data = context.response.json()
assert field in data, (
f"Expected field '{field}' in response JSON but only found: {list(data.keys())}"
)
assert field in data, f"Expected field '{field}' in response JSON but only found: {list(data.keys())}"
@then('the response JSON should have a user with username "{username}"')
@@ -409,9 +388,7 @@ def step_json_user_field_not_empty(context, field):
data = context.response.json()
assert "user" in data, f"No 'user' in response: {list(data.keys())}"
value = data["user"].get(field)
assert value is not None and str(value) != "", (
f"Expected user field '{field}' to be non-empty, got: {value!r}"
)
assert value is not None and str(value) != "", f"Expected user field '{field}' to be non-empty, got: {value!r}"
@then('the response JSON user field "{field}" should equal "{expected}"')
@@ -424,9 +401,7 @@ def step_json_user_field_equals(context, field, expected):
assert "user" in data, f"No 'user' in response: {list(data.keys())}"
value = data["user"].get(field, "")
actual = str(value).lower() if isinstance(value, bool) else str(value)
assert actual == expected, (
f"Expected user field '{field}' == '{expected}' but got '{actual}'"
)
assert actual == expected, f"Expected user field '{field}' == '{expected}' but got '{actual}'"
@then('the response JSON field "{field}" should equal "{expected}"')
@@ -439,10 +414,7 @@ def step_json_top_field_equals(context, field, expected):
data = context.response.json()
value = data.get(field, "")
actual = str(value).lower() if isinstance(value, bool) else str(value)
assert actual == expected, (
f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. "
f"Full response: {data}"
)
assert actual == expected, f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. Full response: {data}"
@then('the response JSON session field "{field}" should be positive')
@@ -459,15 +431,9 @@ def step_json_session_field_positive(context, field):
def step_json_error_contains(context, error_text):
"""Assert the error/message/detail field contains the expected substring (case-insensitive)."""
data = context.response.json()
error = (
data.get("error")
or data.get("message")
or data.get("detail")
or ""
)
error = data.get("error") or data.get("message") or data.get("detail") or ""
assert error_text.lower() in str(error).lower(), (
f"Expected '{error_text}' (case-insensitive) in error response but got: '{error}'. "
f"Full response: {data}"
f"Expected '{error_text}' (case-insensitive) in error response but got: '{error}'. Full response: {data}"
)
@@ -480,9 +446,7 @@ def step_json_error_contains(context, error_text):
def step_store_jwt_from_login(context):
"""Extract and store access_token from the login response."""
data = context.response.json()
assert "session" in data and "access_token" in data["session"], (
f"No access_token in login response: {data}"
)
assert "session" in data and "access_token" in data["session"], f"No access_token in login response: {data}"
context.jwt_token = data["session"]["access_token"]
assert context.jwt_token, "Stored JWT token is empty"
@@ -491,9 +455,7 @@ def step_store_jwt_from_login(context):
def step_update_stored_jwt(context):
"""Replace the stored JWT token with the new one from the current response."""
data = context.response.json()
assert "session" in data and "access_token" in data["session"], (
f"No access_token in response: {data}"
)
assert "session" in data and "access_token" in data["session"], f"No access_token in response: {data}"
new_token = data["session"]["access_token"]
assert new_token, "New JWT token from response is empty"
context.jwt_token = new_token
@@ -12,7 +12,7 @@ Covers:
"""
import requests
from behave import given, then, when
from behave import then, when
BASE_URL = "http://localhost:8080"
@@ -131,9 +131,7 @@ def step_delete_no_auth_and_params(context, endpoint, params):
# ---------------------------------------------------------------------------
@when(
'I use the stored value to send a GET request to "{endpoint_template}" with JWT authentication'
)
@when('I use the stored value to send a GET request to "{endpoint_template}" with JWT authentication')
def step_get_stored_jwt(context, endpoint_template):
"""Send GET request substituting {stored} in the path with context.stored_value."""
endpoint = _expand_stored(endpoint_template, context)
@@ -144,9 +142,7 @@ def step_get_stored_jwt(context, endpoint_template):
)
@when(
'I use the stored value to send a GET request to "{endpoint_template}" with no authentication'
)
@when('I use the stored value to send a GET request to "{endpoint_template}" with no authentication')
def step_get_stored_no_auth(context, endpoint_template):
"""Send GET request substituting {stored} in the path with no authentication."""
endpoint = _expand_stored(endpoint_template, context)
@@ -156,9 +152,7 @@ def step_get_stored_no_auth(context, endpoint_template):
)
@when(
'I use the stored value to send a DELETE request to "{endpoint_template}" with JWT authentication'
)
@when('I use the stored value to send a DELETE request to "{endpoint_template}" with JWT authentication')
def step_delete_stored_jwt(context, endpoint_template):
"""Send DELETE request substituting {stored} in the path with context.stored_value."""
endpoint = _expand_stored(endpoint_template, context)
@@ -169,9 +163,7 @@ def step_delete_stored_jwt(context, endpoint_template):
)
@when(
'I use the stored value to send a POST request to "{endpoint_template}" with JWT authentication'
)
@when('I use the stored value to send a POST request to "{endpoint_template}" with JWT authentication')
def step_post_stored_jwt(context, endpoint_template):
"""Send POST request substituting {stored} in the path with context.stored_value."""
endpoint = _expand_stored(endpoint_template, context)
@@ -207,8 +199,7 @@ def step_json_top_field_not_empty(context, field):
data = context.response.json()
value = data.get(field)
assert value is not None and str(value) != "", (
f"Expected field '{field}' to be non-empty, got: {value!r}. "
f"Full response: {data}"
f"Expected field '{field}' to be non-empty, got: {value!r}. Full response: {data}"
)
@@ -223,8 +214,7 @@ def step_response_is_list(context):
"""Assert that the top-level response JSON value is a list."""
data = context.response.json()
assert isinstance(data, list), (
f"Expected response to be a JSON list but got: {type(data).__name__}. "
f"Content: {str(data)[:200]}"
f"Expected response to be a JSON list but got: {type(data).__name__}. Content: {str(data)[:200]}"
)
@@ -234,8 +224,7 @@ def step_json_field_is_list(context, field):
data = context.response.json()
value = data.get(field)
assert isinstance(value, list), (
f"Expected field '{field}' to be a list but got: {type(value).__name__}. "
f"Full response: {data}"
f"Expected field '{field}' to be a list but got: {type(value).__name__}. Full response: {data}"
)
@@ -245,10 +234,7 @@ def step_json_field_is_true(context, field):
data = context.response.json()
value = data.get(field)
actual = str(value).lower() if isinstance(value, bool) else str(value).lower()
assert actual == "true", (
f"Expected field '{field}' to be true but got: {value!r}. "
f"Full response: {data}"
)
assert actual == "true", f"Expected field '{field}' to be true but got: {value!r}. Full response: {data}"
@then('the response JSON field "{field}" should be false')
@@ -257,7 +243,4 @@ def step_json_field_is_false(context, field):
data = context.response.json()
value = data.get(field)
actual = str(value).lower() if isinstance(value, bool) else str(value).lower()
assert actual == "false", (
f"Expected field '{field}' to be false but got: {value!r}. "
f"Full response: {data}"
)
assert actual == "false", f"Expected field '{field}' to be false but got: {value!r}. Full response: {data}"
@@ -40,9 +40,7 @@ HTTP_TIMEOUT = 30
def _jwt_headers(context):
token = getattr(context, "jwt_token", None)
assert token, (
"No JWT token in context. Use 'Given I am logged in as admin' first."
)
assert token, "No JWT token in context. Use 'Given I am logged in as admin' first."
return {"Authorization": f"Bearer {token}"}
@@ -74,10 +72,7 @@ def _resolve_folder_id(context, ref):
if ref.endswith(".id"):
name = ref[:-3]
_ensure_folders_dict(context)
assert name in context.folders_by_name, (
f"No folder named {name!r} stashed; available: "
f"{list(context.folders_by_name)}"
)
assert name in context.folders_by_name, f"No folder named {name!r} stashed; available: {list(context.folders_by_name)}"
return context.folders_by_name[name]
return ref
@@ -120,9 +115,7 @@ def step_clear_all_folders(context):
early as a clear assertion rather than letting individual steps fail
with cryptic errors.
"""
response = requests.get(
FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
response = requests.get(FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT)
assert response.status_code in (200, 204), (
f"Folder list returned {response.status_code} during teardown - "
f"is the proprietary storage-folders module deployed? Body: "
@@ -145,8 +138,7 @@ def step_clear_all_folders(context):
def step_folder_exists(context, name):
response = _create_folder(context, name)
assert response.status_code == 201, (
f"Could not create folder {name!r} during Given step: "
f"{response.status_code} {response.text}"
f"Could not create folder {name!r} during Given step: {response.status_code} {response.text}"
)
_ensure_folders_dict(context)
context.folders_by_name[name] = response.json()["id"]
@@ -157,15 +149,9 @@ def step_folder_exists(context, name):
def step_folder_exists_under(context, name, parent):
_ensure_folders_dict(context)
parent_id = context.folders_by_name.get(parent)
assert parent_id, (
f"Parent folder {parent!r} not created yet; available: "
f"{list(context.folders_by_name)}"
)
assert parent_id, f"Parent folder {parent!r} not created yet; available: {list(context.folders_by_name)}"
response = _create_folder(context, name, parent_id=parent_id)
assert response.status_code == 201, (
f"Could not create child folder {name!r}: "
f"{response.status_code} {response.text}"
)
assert response.status_code == 201, f"Could not create child folder {name!r}: {response.status_code} {response.text}"
context.folders_by_name[name] = response.json()["id"]
context.response = response
@@ -254,9 +240,7 @@ def step_delete_folder(context, name):
@when("I list folders")
def step_list_folders(context):
response = requests.get(
FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
response = requests.get(FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT)
context.response = response
@@ -270,10 +254,7 @@ def step_list_folders(context):
def step_patch_file_folder(context, filename, folder_ref):
_ensure_files_dict(context)
file_id = context.uploaded_files.get(filename)
assert file_id, (
f"File {filename!r} not uploaded yet; available: "
f"{list(context.uploaded_files)}"
)
assert file_id, f"File {filename!r} not uploaded yet; available: {list(context.uploaded_files)}"
folder_id = _resolve_folder_id(context, folder_ref)
response = requests.patch(
f"{FILES_URL}/{file_id}/folder",
@@ -286,9 +267,7 @@ def step_patch_file_folder(context, filename, folder_ref):
@when("I list files")
def step_list_files(context):
response = requests.get(
FILES_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
response = requests.get(FILES_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT)
context.response = response
@@ -322,52 +301,39 @@ def step_post_folder_no_auth(context, name):
@then('the response JSON folder should have name "{name}"')
def step_response_folder_name(context, name):
data = context.response.json()
assert data.get("name") == name, (
f"Expected name={name!r}, got {data.get('name')!r}. Body: {data}"
)
assert data.get("name") == name, f"Expected name={name!r}, got {data.get('name')!r}. Body: {data}"
@then("the response JSON folder.parentFolderId should be null")
def step_response_folder_parent_null(context):
data = context.response.json()
assert data.get("parentFolderId") is None, (
f"Expected parentFolderId=null, got {data.get('parentFolderId')!r}"
)
assert data.get("parentFolderId") is None, f"Expected parentFolderId=null, got {data.get('parentFolderId')!r}"
@then('the response JSON folder.parentFolderId should equal "{ref}"')
def step_response_folder_parent_equal(context, ref):
data = context.response.json()
expected = _resolve_folder_id(context, ref)
assert data.get("parentFolderId") == expected, (
f"Expected parentFolderId={expected!r}, "
f"got {data.get('parentFolderId')!r}"
)
assert data.get("parentFolderId") == expected, f"Expected parentFolderId={expected!r}, got {data.get('parentFolderId')!r}"
@then("the response JSON folder.createdAt should not be empty")
def step_response_folder_createdat_not_empty(context):
data = context.response.json()
assert data.get("createdAt"), (
f"Expected non-empty createdAt; body: {data}"
)
assert data.get("createdAt"), f"Expected non-empty createdAt; body: {data}"
@then("the response JSON file.folderId should be null")
def step_response_file_folderid_null(context):
data = context.response.json()
assert data.get("folderId") is None, (
f"Expected folderId=null, got {data.get('folderId')!r}"
)
assert data.get("folderId") is None, f"Expected folderId=null, got {data.get('folderId')!r}"
@then('the response JSON file.folderId should equal "{ref}"')
def step_response_file_folderid_equal(context, ref):
data = context.response.json()
expected = _resolve_folder_id(context, ref)
assert data.get("folderId") == expected, (
f"Expected folderId={expected!r}, got {data.get('folderId')!r}"
)
assert data.get("folderId") == expected, f"Expected folderId={expected!r}, got {data.get('folderId')!r}"
@then('the folder list should contain "{name}"')
@@ -381,19 +347,13 @@ def step_folder_list_contains(context, name):
def step_folder_list_not_contains(context, name):
folders = context.response.json()
names = [f.get("name") for f in folders]
assert name not in names, (
f"Folder {name!r} should not be in list: {names}"
)
assert name not in names, f"Folder {name!r} should not be in list: {names}"
@then('the file list should contain a file named "{filename}" with folderId null')
def step_file_list_contains_root_file(context, filename):
files = context.response.json()
matches = [
f
for f in files
if f.get("fileName") == filename and f.get("folderId") is None
]
matches = [f for f in files if f.get("fileName") == filename and f.get("folderId") is None]
assert matches, (
f"No file named {filename!r} with folderId=null in list. "
f"Files seen: "
@@ -24,7 +24,10 @@ ADMIN_PASS = "stirling"
def _sh(args, stdin=None, timeout=60):
"""Run a command, return (returncode, stdout, stderr)."""
r = subprocess.run(
args, input=stdin, capture_output=True, timeout=timeout,
args,
input=stdin,
capture_output=True,
timeout=timeout,
text=(stdin is None or isinstance(stdin, str)),
)
out = r.stdout if isinstance(r.stdout, str) else r.stdout.decode("utf-8", "replace")
@@ -34,9 +37,7 @@ def _sh(args, stdin=None, timeout=60):
def _psql(query):
"""Run a query against the shared Postgres, return the raw tab/newline output (trimmed)."""
rc, out, err = _sh(
["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query]
)
rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", query])
assert rc == 0, f"psql failed: {err.strip() or out.strip()}"
return out.strip()
@@ -47,10 +48,7 @@ def _psql_int(query):
def _network():
rc, out, _ = _sh(
["docker", "inspect", "-f",
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]]
)
rc, out, _ = _sh(["docker", "inspect", "-f", "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]])
return out.strip() or "compose_stirling-multinode"
@@ -89,18 +87,25 @@ def _names_on_node(node, path, token):
data = json.loads(body)
except ValueError:
return set()
items = data if isinstance(data, list) else data.get(path.rsplit("/", 1)[-1], []) \
or data.get("sources", []) or data.get("policies", [])
items = (
data
if isinstance(data, list)
else data.get(path.rsplit("/", 1)[-1], []) or data.get("sources", []) or data.get("policies", [])
)
return {i.get("name") for i in items if isinstance(i, dict)}
def _policy_body(name, source_ids=None, enabled=True):
return json.dumps({
"name": name, "enabled": enabled, "trigger": None,
"sourceIds": source_ids or [],
"steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}],
"output": {"type": "inline", "options": {}},
})
return json.dumps(
{
"name": name,
"enabled": enabled,
"trigger": None,
"sourceIds": source_ids or [],
"steps": [{"operation": "/api/v1/misc/compress-pdf", "parameters": {}}],
"output": {"type": "inline", "options": {}},
}
)
def _any_connection_id(context):
@@ -108,8 +113,7 @@ def _any_connection_id(context):
cid = getattr(context, "_seed_conn_id", None)
if cid:
return cid
r = requests.get(f"{LB_URL}/api/v1/integrations",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
r = requests.get(f"{LB_URL}/api/v1/integrations", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list integrations failed: HTTP {r.status_code}"
s3 = next((c for c in r.json() if c.get("integrationType") == "S3"), None)
assert s3, "no S3 connection available (did the seed run?)"
@@ -119,33 +123,45 @@ def _any_connection_id(context):
def _s3_source_body(name, connection_id):
# Folder sources are config-gated; S3 sources against the seeded connection always work.
return json.dumps({
"name": name, "type": "s3",
"options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"},
"enabled": True,
})
return json.dumps(
{
"name": name,
"type": "s3",
"options": {"connectionId": connection_id, "prefix": "regr/", "mode": "snapshot"},
"enabled": True,
}
)
def _source_id_by_name(context, name):
r = requests.get(f"{LB_URL}/api/v1/sources",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
r = requests.get(f"{LB_URL}/api/v1/sources", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list sources failed: HTTP {r.status_code}"
return next((s["id"] for s in r.json().get("sources", []) if s.get("name") == name), None)
def _s3_connection_body(name):
return json.dumps({
"integrationType": "S3", "name": name, "scope": "SERVER", "enabled": True,
"locked": False, "defaultAccess": "ORG_ALL",
"config": {"bucket": BUCKET, "region": "us-east-1", "endpoint": "http://minio:9000",
"accessKeyId": "minioadmin", "secretAccessKey": "minioadmin",
"pathStyleAccess": True},
})
return json.dumps(
{
"integrationType": "S3",
"name": name,
"scope": "SERVER",
"enabled": True,
"locked": False,
"defaultAccess": "ORG_ALL",
"config": {
"bucket": BUCKET,
"region": "us-east-1",
"endpoint": "http://minio:9000",
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin",
"pathStyleAccess": True,
},
}
)
def _lb_login(context):
r = requests.post(f"{LB_URL}/api/v1/auth/login",
json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15)
r = requests.post(f"{LB_URL}/api/v1/auth/login", json={"username": ADMIN_USER, "password": ADMIN_PASS}, timeout=15)
assert r.status_code == 200, f"admin login via LB failed: HTTP {r.status_code}"
context.jwt_token = r.json()["session"]["access_token"]
@@ -154,6 +170,7 @@ def _pdf_bytes(marker):
"""A minimal valid single-page PDF carrying a unique marker (so outputs are identifiable)."""
try:
from reportlab.pdfgen import canvas
buf = io.BytesIO()
c = canvas.Canvas(buf)
c.drawString(100, 750, f"multinode-regression {marker}")
@@ -162,10 +179,12 @@ def _pdf_bytes(marker):
return buf.getvalue()
except Exception:
# Fallback: a hand-rolled minimal PDF if reportlab is unavailable.
return (b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF")
return (
b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
b"trailer<</Root 1 0 R>>\n%%EOF"
)
def _mc(context, script, stdin=None):
@@ -173,14 +192,12 @@ def _mc(context, script, stdin=None):
net = getattr(context, "_net", None) or _network()
context._net = net
full = f"mc alias set local http://minio:9000 minioadmin minioadmin >/dev/null 2>&1 && {script}"
args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh",
"minio/mc", "-c", full]
args = ["docker", "run", "-i", "--rm", "--network", net, "--entrypoint", "/bin/sh", "minio/mc", "-c", full]
return _sh(args, stdin=stdin, timeout=90)
def _policy_id_by_name(context, name):
r = requests.get(f"{LB_URL}/api/v1/policies",
headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
r = requests.get(f"{LB_URL}/api/v1/policies", headers={"Authorization": f"Bearer {_token(context)}"}, timeout=15)
assert r.status_code == 200, f"list policies failed: HTTP {r.status_code}"
data = r.json()
items = data if isinstance(data, list) else data.get("policies", [])
@@ -227,8 +244,8 @@ def step_lb_requests(context, endpoint, count):
def step_distinct_nodes(context, n):
distinct = set(context._served_by)
assert len(distinct) >= n, (
f"expected >= {n} distinct upstreams, saw {sorted(distinct)} "
f"(is the X-Served-By header configured on the LB?)")
f"expected >= {n} distinct upstreams, saw {sorted(distinct)} (is the X-Served-By header configured on the LB?)"
)
@then("every load-balanced response should be {code:d}")
@@ -248,8 +265,9 @@ def step_token_every_node(context):
@then("the signing keys should be stored in the shared database")
def step_keys_in_db(context):
assert _psql_int("select count(*) from jwt_signing_keys") >= 1, \
assert _psql_int("select count(*) from jwt_signing_keys") >= 1, (
"no rows in jwt_signing_keys - keys are not persisted in the shared DB"
)
@then("every stored private key should be encrypted at rest")
@@ -263,9 +281,9 @@ def step_keys_encrypted(context):
@when('I create a team named "{name}" through the load balancer')
def step_create_team(context, name):
context._team_name = name
r = requests.post(f"{LB_URL}/api/v1/team/create",
headers={"Authorization": f"Bearer {_token(context)}"},
data={"name": name}, timeout=15)
r = requests.post(
f"{LB_URL}/api/v1/team/create", headers={"Authorization": f"Bearer {_token(context)}"}, data={"name": name}, timeout=15
)
assert r.status_code in (200, 201, 409), f"create team failed: HTTP {r.status_code}"
@@ -335,9 +353,7 @@ def step_files_processed(context, seconds):
if remaining == 0:
break
time.sleep(3)
assert remaining == 0, (
f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after "
f"{seconds}s")
assert remaining == 0, f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after {seconds}s"
for node in NODES:
rc, out, _ = _sh(["docker", "inspect", "-f", "{{.State.Status}}", node])
assert out.strip() == "running", f"{node} crashed during concurrent processing"
@@ -348,15 +364,19 @@ def step_ledger_claim_atomic(context):
# Exactly-once relies on the (identity_hash, policy_id) primary key: two nodes claiming the same file both insert it, but only one wins; this proves the constraint rejects the second claim.
ihash = "regr-" + uuid.uuid4().hex
pol = "regr-policy-" + uuid.uuid4().hex[:8]
insert = (f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) "
f"values ('{ihash}', '{pol}', 'PROCESSING', 1)")
insert = (
f"insert into policy_processed_files (identity_hash, policy_id, status, attempts) "
f"values ('{ihash}', '{pol}', 'PROCESSING', 1)"
)
_psql(insert) # first claim wins
rc, out, err = _sh(["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling",
"-tAc", insert]) # second claim must be rejected
rc, out, err = _sh(
["docker", "exec", PG, "psql", "-U", "stirling", "-d", "stirling", "-tAc", insert]
) # second claim must be rejected
_psql(f"delete from policy_processed_files where identity_hash = '{ihash}'")
assert rc != 0 and "duplicate key" in (out + err).lower(), (
"a second claim for the same file and policy was NOT rejected - the ledger's exactly-once "
"guarantee is not enforced by the primary key")
"guarantee is not enforced by the primary key"
)
# --------------------------------------------------------------------------- policy run coordination (gap)
@@ -368,8 +388,7 @@ def step_run_policy_on_node(context, name, idx):
assert pid, f"policy '{name}' not found"
# Drop an input so the trigger actually produces a run (the source is otherwise empty).
marker = uuid.uuid4().hex[:12]
_mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf",
stdin=_pdf_bytes(marker))
_mc(context, f"mc pipe local/{BUCKET}/{SOURCE_PREFIX}runvis-{marker}.pdf", stdin=_pdf_bytes(marker))
status, _ = _curl_on_node(node, "POST", f"/api/v1/policies/{pid}/trigger", token=_token(context))
assert status in (200, 202), f"triggering the policy on {node} failed: HTTP {status}"
# Grab the runId that node recorded for the run it just executed.
@@ -393,7 +412,8 @@ def step_run_visible_every(context):
assert context._run_id in run_ids, (
f"run {context._run_id} (executed on {context._run_node}) is not visible from {node} - "
f"PolicyRunRegistry is a per-node in-JVM map, so run status and cancellation do not "
f"cross nodes")
f"cross nodes"
)
# --------------------------------------------------------------------------- rate limiting
@@ -401,9 +421,21 @@ def step_run_visible_every(context):
def step_ratelimit_shared(context):
# In cluster mode the ValkeyRateLimitStore holds counters in Valkey; probe that a key exists.
net = context._net or _network()
rc, out, err = _sh(["docker", "run", "--rm", "--network", net, "--entrypoint", "/bin/sh",
"valkey/valkey:8-alpine", "-c",
"valkey-cli -h valkey keys '*'"], timeout=30)
rc, out, err = _sh(
[
"docker",
"run",
"--rm",
"--network",
net,
"--entrypoint",
"/bin/sh",
"valkey/valkey:8-alpine",
"-c",
"valkey-cli -h valkey keys '*'",
],
timeout=30,
)
assert rc == 0, f"valkey probe failed: {err.strip()}"
assert out.strip(), "no keys in Valkey - rate-limit/backplane state is not shared"
@@ -458,8 +490,9 @@ def _auth(context):
# --- policies ---
@when('I create a policy named "{name}" on node "{idx}"')
def step_create_policy_on_node(context, name, idx):
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/policies", token=_token(context),
data=_policy_body(name), content_type="application/json")
status, body = _curl_on_node(
_node(idx), "POST", "/api/v1/policies", token=_token(context), data=_policy_body(name), content_type="application/json"
)
assert status in (200, 201), f"create policy on {_node(idx)} failed: HTTP {status}: {body[:200]}"
@@ -467,9 +500,12 @@ def step_create_policy_on_node(context, name, idx):
def step_create_policy_ref(context, name, src):
sid = _source_id_by_name(context, src)
assert sid, f"source '{src}' not found"
r = requests.post(f"{LB_URL}/api/v1/policies",
headers={**_auth(context), "Content-Type": "application/json"},
data=_policy_body(name, [sid]), timeout=15)
r = requests.post(
f"{LB_URL}/api/v1/policies",
headers={**_auth(context), "Content-Type": "application/json"},
data=_policy_body(name, [sid]),
timeout=15,
)
assert r.status_code in (200, 201), f"create referencing policy failed: HTTP {r.status_code}"
@@ -479,9 +515,9 @@ def step_rename_policy(context, old, new):
assert pid, f"policy '{old}' not found"
pol = requests.get(f"{LB_URL}/api/v1/policies/{pid}", headers=_auth(context), timeout=15).json()
pol["name"] = new
r = requests.post(f"{LB_URL}/api/v1/policies",
headers={**_auth(context), "Content-Type": "application/json"},
json=pol, timeout=15)
r = requests.post(
f"{LB_URL}/api/v1/policies", headers={**_auth(context), "Content-Type": "application/json"}, json=pol, timeout=15
)
assert r.status_code in (200, 201), f"rename policy failed: HTTP {r.status_code}"
@@ -524,8 +560,14 @@ def step_triggers_identical(context):
@when('I create an S3 source named "{name}" on node "{idx}"')
def step_create_source_on_node(context, name, idx):
conn = _any_connection_id(context)
status, body = _curl_on_node(_node(idx), "POST", "/api/v1/sources", token=_token(context),
data=_s3_source_body(name, conn), content_type="application/json")
status, body = _curl_on_node(
_node(idx),
"POST",
"/api/v1/sources",
token=_token(context),
data=_s3_source_body(name, conn),
content_type="application/json",
)
assert status in (200, 201), f"create source on {_node(idx)} failed: HTTP {status}: {body[:200]}"
@@ -556,16 +598,18 @@ def step_source_delete_guarded(context, name, idx):
sid = _source_id_by_name(context, name)
assert sid, f"source '{name}' not found"
status, body = _curl_on_node(_node(idx), "DELETE", f"/api/v1/sources/{sid}", token=_token(context))
assert status == 409, (
f"expected 409 (source referenced by a policy created on another node), got HTTP {status}")
assert status == 409, f"expected 409 (source referenced by a policy created on another node), got HTTP {status}"
# --- connections (integration configs) ---
@when('I create an S3 connection named "{name}" via the load balancer')
def step_create_conn(context, name):
r = requests.post(f"{LB_URL}/api/v1/integrations",
headers={**_auth(context), "Content-Type": "application/json"},
data=_s3_connection_body(name), timeout=15)
r = requests.post(
f"{LB_URL}/api/v1/integrations",
headers={**_auth(context), "Content-Type": "application/json"},
data=_s3_connection_body(name),
timeout=15,
)
assert r.status_code in (200, 201), f"create connection failed: HTTP {r.status_code}: {r.text[:200]}"
context._conn_id = r.json()["id"]
@@ -580,10 +624,9 @@ def step_conn_resolves(context, name):
assert secret in (None, "", "********"), f"{node} leaked the connection secret on read"
@when('I delete the connection via the load balancer')
@when("I delete the connection via the load balancer")
def step_delete_conn(context):
r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}",
headers=_auth(context), timeout=15)
r = requests.delete(f"{LB_URL}/api/v1/integrations/{context._conn_id}", headers=_auth(context), timeout=15)
assert r.status_code in (200, 204), f"delete connection failed: HTTP {r.status_code}"
@@ -1,19 +1,20 @@
import json as json_module
import os
import requests
from behave import given, when, then
from pypdf import PdfWriter, PdfReader
from pypdf.errors import PdfReadError
import io
import json as json_module
import mimetypes
import os
import random
import re
import string
import zipfile
import requests
from behave import given, then, when
from PIL import Image, ImageDraw
from pypdf import PdfReader, PdfWriter
from pypdf.errors import PdfReadError
from reportlab.lib.pagesizes import letter
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
import mimetypes
import zipfile
import re
from PIL import Image, ImageDraw
API_HEADERS = {"X-API-KEY": "123456789"}
@@ -99,15 +100,11 @@ def create_black_box_image(file_name, size):
can.save()
@given(
"the pdf contains {image_count:d} images of size {width:d}x{height:d} on {page_count:d} pages"
)
@given("the pdf contains {image_count:d} images of size {width:d}x{height:d} on {page_count:d} pages")
def step_impl(context, image_count, width, height, page_count):
context.param_name = "fileInput"
context.file_name = "genericNonCustomisableName.pdf"
create_pdf_with_images_and_boxes(
context.file_name, image_count, page_count, width, height
)
create_pdf_with_images_and_boxes(context.file_name, image_count, page_count, width, height)
if not hasattr(context, "files"):
context.files = {}
context.files[context.param_name] = open(context.file_name, "rb")
@@ -122,13 +119,9 @@ def add_black_boxes_to_image(image):
return image
def create_pdf_with_images_and_boxes(
file_name, image_count, page_count, image_width, image_height
):
def create_pdf_with_images_and_boxes(file_name, image_count, page_count, image_width, image_height):
page_width, page_height = max(letter[0], image_width), max(letter[1], image_height)
boxes_per_page = image_count // page_count + (
1 if image_count % page_count != 0 else 0
)
boxes_per_page = image_count // page_count + (1 if image_count % page_count != 0 else 0)
writer = PdfWriter()
box_counter = 0
@@ -143,9 +136,7 @@ def create_pdf_with_images_and_boxes(
# Simulating a dynamic image creation (replace this with your actual image creation logic)
# For demonstration, we'll create a simple black image
dummy_image = Image.new(
"RGB", (image_width, image_height), color="white"
) # Create a white image
dummy_image = Image.new("RGB", (image_width, image_height), color="white") # Create a white image
dummy_image = add_black_boxes_to_image(dummy_image) # Add black boxes
# Convert the PIL Image to bytes to pass to drawImage
@@ -161,9 +152,7 @@ def create_pdf_with_images_and_boxes(
break
# Add the image to the PDF
can.drawImage(
ImageReader(image_bytes), x, y, width=image_width, height=image_height
)
can.drawImage(ImageReader(image_bytes), x, y, width=image_width, height=image_height)
box_counter += 1
can.showPage()
@@ -206,9 +195,7 @@ def create_pdf_with_black_boxes(file_name, image_count, page_count):
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=(page_width, page_height))
boxes_per_page = image_count // page_count + (
1 if image_count % page_count != 0 else 0
)
boxes_per_page = image_count // page_count + (1 if image_count % page_count != 0 else 0)
for i in range(boxes_per_page):
if box_counter >= image_count:
break
@@ -496,9 +483,7 @@ def step_pdf_has_attachment(context, attachment_name):
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
attachment_bytes = (
f"Attachment: {attachment_name}\nThis is test attachment content.".encode("utf-8")
)
attachment_bytes = f"Attachment: {attachment_name}\nThis is test attachment content.".encode()
writer.add_attachment(attachment_name, attachment_bytes)
with open(context.file_name, "wb") as f:
writer.write(f)
@@ -527,10 +512,7 @@ def step_pdf_has_qr_split_marker(context, page_num):
try:
import qrcode as _qrcode
except ImportError:
raise ImportError(
"qrcode package is required for this step. "
"Install with: pip install 'qrcode[pil]'"
)
raise ImportError("qrcode package is required for this step. Install with: pip install 'qrcode[pil]'")
reader = PdfReader(context.file_name)
qr = _qrcode.QRCode(box_size=4, border=2)
qr.add_data("https://github.com/Stirling-Tools/Stirling-PDF")
@@ -545,9 +527,7 @@ def step_pdf_has_qr_split_marker(context, page_num):
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
w, h = letter
can.drawImage(
ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100
)
can.drawImage(ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100)
can.showPage()
can.save()
packet.seek(0)
@@ -647,9 +627,9 @@ def step_send_api_request(context, endpoint):
@then('the response content type should be "{content_type}"')
def step_check_response_content_type(context, content_type):
actual_content_type = context.response.headers.get("Content-Type", "")
assert actual_content_type.startswith(
content_type
), f"Expected {content_type} but got {actual_content_type}. Response content: {context.response.content}"
assert actual_content_type.startswith(content_type), (
f"Expected {content_type} but got {actual_content_type}. Response content: {context.response.content}"
)
@then("the response file should have size greater than {size:d}")
@@ -672,20 +652,16 @@ def step_check_response_pdf_passworded(context):
reader = PdfReader(response_file)
assert reader.is_encrypted
except PdfReadError as e:
raise AssertionError(
f"Failed to read PDF: {str(e)}. Response content: {context.response.content}"
)
raise AssertionError(f"Failed to read PDF: {str(e)}. Response content: {context.response.content}")
except Exception as e:
raise AssertionError(
f"An error occurred: {str(e)}. Response content: {context.response.content}"
)
raise AssertionError(f"An error occurred: {str(e)}. Response content: {context.response.content}")
@then("the response status code should be {status_code:d}")
def step_check_response_status_code(context, status_code):
assert (
context.response.status_code == status_code
), f"Expected status code {status_code} but got {context.response.status_code}"
assert context.response.status_code == status_code, (
f"Expected status code {status_code} but got {context.response.status_code}"
)
@then('the response should contain error message "{message}"')
@@ -693,9 +669,7 @@ def step_check_response_error_message(context, message):
response_json = context.response.json()
# Check for error message in both "error" (old format) and "detail" (RFC 7807 ProblemDetail)
error_message = response_json.get("error") or response_json.get("detail")
assert (
error_message == message
), f"Expected error message '{message}' but got '{error_message}'"
assert error_message == message, f"Expected error message '{message}' but got '{error_message}'"
@then('the response PDF metadata should include "{metadata_key}" as "{metadata_value}"')
@@ -703,9 +677,9 @@ def step_check_response_pdf_metadata(context, metadata_key, metadata_value):
response_file = io.BytesIO(context.response.content)
reader = PdfReader(response_file)
metadata = reader.metadata
assert (
metadata.get("/" + metadata_key) == metadata_value
), f"Expected {metadata_key} to be '{metadata_value}' but got '{metadata.get(metadata_key)}'"
assert metadata.get("/" + metadata_key) == metadata_value, (
f"Expected {metadata_key} to be '{metadata_value}' but got '{metadata.get(metadata_key)}'"
)
@then('the response file should have extension "{extension}"')
@@ -718,9 +692,9 @@ def step_check_response_file_extension(context, extension):
if part.strip().startswith("filename"):
filename = part.split("=")[1].strip().strip('"')
break
assert filename.endswith(
extension
), f"Expected file extension {extension} but got {filename}. Response content: {context.response.content}"
assert filename.endswith(extension), (
f"Expected file extension {extension} but got {filename}. Response content: {context.response.content}"
)
@then('save the response file as "{filename}" for debugging')
@@ -735,9 +709,7 @@ def step_check_response_pdf_page_count(context, page_count):
response_file = io.BytesIO(context.response.content)
reader = PdfReader(io.BytesIO(response_file.getvalue()))
actual_page_count = len(reader.pages)
assert (
actual_page_count == page_count
), f"Expected {page_count} pages but got {actual_page_count} pages"
assert actual_page_count == page_count, f"Expected {page_count} pages but got {actual_page_count} pages"
@then("the response ZIP should contain {file_count:d} files")
@@ -745,61 +717,45 @@ def step_check_response_zip_file_count(context, file_count):
response_file = io.BytesIO(context.response.content)
with zipfile.ZipFile(io.BytesIO(response_file.getvalue())) as zip_file:
actual_file_count = len(zip_file.namelist())
assert (
actual_file_count == file_count
), f"Expected {file_count} files but got {actual_file_count} files"
assert actual_file_count == file_count, f"Expected {file_count} files but got {actual_file_count} files"
@then(
"the response ZIP file should contain {doc_count:d} documents each having {pages_per_doc:d} pages"
)
@then("the response ZIP file should contain {doc_count:d} documents each having {pages_per_doc:d} pages")
def step_check_response_zip_doc_page_count(context, doc_count, pages_per_doc):
response_file = io.BytesIO(context.response.content)
with zipfile.ZipFile(io.BytesIO(response_file.getvalue())) as zip_file:
actual_doc_count = len(zip_file.namelist())
assert (
actual_doc_count == doc_count
), f"Expected {doc_count} documents but got {actual_doc_count} documents"
assert actual_doc_count == doc_count, f"Expected {doc_count} documents but got {actual_doc_count} documents"
for file_name in zip_file.namelist():
with zip_file.open(file_name) as pdf_file:
reader = PdfReader(pdf_file)
actual_pages_per_doc = len(reader.pages)
assert (
actual_pages_per_doc == pages_per_doc
), f"Expected {pages_per_doc} pages per document but got {actual_pages_per_doc} pages in document {file_name}"
assert actual_pages_per_doc == pages_per_doc, (
f"Expected {pages_per_doc} pages per document but got {actual_pages_per_doc} pages in document {file_name}"
)
@then('the JSON value of "{key}" should be "{expected_value}"')
def step_check_json_value(context, key, expected_value):
actual_value = context.response.json().get(key)
assert (
actual_value == expected_value
), f"Expected JSON value for '{key}' to be '{expected_value}' but got '{actual_value}'"
assert actual_value == expected_value, f"Expected JSON value for '{key}' to be '{expected_value}' but got '{actual_value}'"
@then(
'JSON list entry containing "{identifier_key}" as "{identifier_value}" should have "{target_key}" as "{target_value}"'
)
def step_check_json_list_entry(
context, identifier_key, identifier_self, target_key, target_value
):
@then('JSON list entry containing "{identifier_key}" as "{identifier_value}" should have "{target_key}" as "{target_value}"')
def step_check_json_list_entry(context, identifier_key, identifier_self, target_key, target_value):
json_response = context.response.json()
for entry in json_response:
if entry.get(identifier_key) == identifier_value:
assert (
entry.get(target_key) == target_value
), f"Expected {target_key} to be {target_value} in entry where {identifier_key} is {identifier_value}, but found {entry.get(target_key)}"
if entry.get(identifier_key) == identifier_self:
assert entry.get(target_key) == target_value, (
f"Expected {target_key} to be {target_value} in entry where {identifier_key} is {identifier_self}, but found {entry.get(target_key)}"
)
break
else:
raise AssertionError(
f"No entry with {identifier_key} as {identifier_value} found"
)
raise AssertionError(f"No entry with {identifier_key} as {identifier_self} found")
@then('the response should match the regex "{pattern}"')
def step_response_matches_regex(context, pattern):
response_text = context.response.text
assert re.match(
pattern, response_text
), f"Response '{response_text}' does not match the expected pattern '{pattern}'"
assert re.match(pattern, response_text), f"Response '{response_text}' does not match the expected pattern '{pattern}'"
@@ -2,7 +2,7 @@ import hashlib
import hmac
import requests
from behave import given, when, then
from behave import given, then, when
BASE_URL = "http://localhost:8080"
API_HEADERS = {"X-API-KEY": "123456789"}
+44 -26
View File
@@ -57,9 +57,11 @@ def create_clean_invoice():
pdf.cell(0, 8, "Grand Total: $8,195.00", new_x="LMARGIN", new_y="NEXT")
pdf.ln(3)
pdf.cell(
0, 8,
0,
8,
"Breakdown: $6,000.00 + $1,000.00 + $450.00 = $7,450.00",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/clean_invoice.pdf")
@@ -87,9 +89,11 @@ def create_tally_error():
pdf.ln(5)
pdf.cell(
0, 8,
0,
8,
"Total Q1 spend: $68,000 + $66,000 + $71,200 = $205,200",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/tally_error.pdf")
@@ -156,12 +160,9 @@ def create_consistency_error():
_body(pdf)
_table_row(pdf, ["Metric", "Q1", "Q2", "Q3", "Q4", "FY2025"], bold=True)
_table_row(pdf, ["Revenue", "$5,100,000", "$5,800,000", "$6,200,000",
"$7,200,000", "$24,300,000"])
_table_row(pdf, ["Expenses", "$4,300,000", "$4,400,000", "$4,600,000",
"$4,900,000", "$18,200,000"])
_table_row(pdf, ["Profit", "$800,000", "$1,400,000", "$1,600,000",
"$2,300,000", "$6,100,000"])
_table_row(pdf, ["Revenue", "$5,100,000", "$5,800,000", "$6,200,000", "$7,200,000", "$24,300,000"])
_table_row(pdf, ["Expenses", "$4,300,000", "$4,400,000", "$4,600,000", "$4,900,000", "$18,200,000"])
_table_row(pdf, ["Profit", "$800,000", "$1,400,000", "$1,600,000", "$2,300,000", "$6,100,000"])
pdf.ln(5)
# BUG: Page 1 says Total Revenue = $24,500,000
@@ -169,9 +170,11 @@ def create_consistency_error():
# Page 1 says Net Profit = $6,300,000
# Page 2 table says Profit FY2025 = $6,100,000
pdf.cell(
0, 8,
0,
8,
"Full-year revenue of $24,300,000 exceeded targets by 8%.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/consistency_error.pdf")
@@ -199,15 +202,19 @@ def create_mixed_errors():
pdf.ln(5)
# BUG: 51000 + 42000 + 29250 + 61500 = 183,750, NOT 182,750
pdf.cell(
0, 8,
0,
8,
"Total revenue: $51,000 + $42,000 + $29,250 + $61,500 = $182,750",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.ln(3)
pdf.cell(
0, 8,
0,
8,
"Commission rate: 10% across all regions.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/mixed_errors.pdf")
@@ -235,37 +242,47 @@ def create_statement_errors():
# Correct claim: profit grew from 2.5M to 3.1M = 24% growth
pdf.cell(
0, 8,
0,
8,
"Profit grew 24% year-over-year, from $2,500,000 to $3,100,000.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
# BUG: Revenue grew from 10M to 11.2M = 12% growth, NOT 15%
pdf.cell(
0, 8,
0,
8,
"Revenue increased 15% compared to the prior year.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
# BUG: Expenses went UP from 7.5M to 8.1M, NOT decreased
pdf.cell(
0, 8,
0,
8,
"Operating expenses decreased year-over-year.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
# BUG: Headcount grew from 142 to 187 = 31.7%, NOT 25%
pdf.cell(
0, 8,
0,
8,
"The team expanded by 25%, growing from 142 to 187 employees.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
# Correct claim: profit margin = 3.1M / 11.2M = 27.68%
pdf.cell(
0, 8,
0,
8,
"Net profit margin reached approximately 28%.",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/statement_errors.pdf")
@@ -274,6 +291,7 @@ def create_statement_errors():
if __name__ == "__main__":
import os
os.makedirs("testing/ledger", exist_ok=True)
print("Generating test PDFs:")
create_clean_invoice()