mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3529fa5b | ||
|
|
a2606e07b6 | ||
|
|
fcb474243c | ||
|
|
54d1bbe04e | ||
|
|
b7aa0b07c1 | ||
|
|
ef63ba83a2 | ||
|
|
a59196db69 | ||
|
|
bfa86b099d | ||
|
|
4bfc3fbb4b | ||
|
|
8be041c4be | ||
|
|
2c11772b7f |
@@ -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:")
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,3 +46,6 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
+16
-9
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Generated
+156
-120
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user