Compare commits

...
Author SHA1 Message Date
Ludy 11aef9eb7a Merge branch 'main' into update_python_dep_20260812 2026-08-25 00:19:46 +02:00
Ludy 193a5231a0 Merge branch 'main' into update_python_dep_20260812 2026-08-23 13:20:17 +02:00
Ludy 5efefa51d7 Merge branch 'main' into update_python_dep_20260812 2026-08-21 09:58:36 +02:00
Ludy87 e4bb1b4b9f Tidy formatting in cucumber tests
Style cleanup across testing/cucumber features: compacted multi-line f-strings, normalized kwargs formatting (headers/timeout), removed stray blank lines and minor whitespace adjustments. Affected files: testing/cucumber/features/environment.py, job_step_definitions.py, job_support.py, parallel_support.py. No functional behavior changes intended.
2026-08-16 16:03:35 +02:00
Ludy87 444f6ea01f Clean up imports in cucumber features
Reorder and simplify imports in testing/cucumber feature files: remove noqa comments in environment.py, import parallel_support consistently, remove an unused os import in parallel_support.py, and consolidate/add parallel_support imports in step definition modules to satisfy linters and remove redundancy.
2026-08-16 15:56:04 +02:00
Ludy 580ad97321 Merge branch 'main' into update_python_dep_20260812 2026-08-16 15:45:07 +02:00
Ludy87 639e3652b0 Reformat tool models and IO spec lines
Reflowed long ToolIOSpec and Field initializers across multiple lines for readability in engine/src/stirling/models/tool_io.py and engine/src/stirling/models/tool_models.py. Purely formatting changes — no logic or behavior modified.
2026-08-13 11:51:48 +02:00
Ludy87 b39df8ca14 Format: wrap long lines and minor reflows
Reflowed and wrapped long Python lines, added inline noqa where needed, and adjusted multi-line comprehensions/expressions across various scripts and tests (.github/scripts, app/core/static, engine/scripts & tests, scripts/translations, testing/cucumber step definitions). Purely formatting changes to satisfy linters/line-length checks; no functional logic was altered.
2026-08-13 11:34:25 +02:00
Ludy87 84cb0d8725 Format: wrap long lines to 120 cols
Reduce ruff line-length to 120 and reflow/wrap long lines across the engine package to satisfy linting. Minor formatting changes: broken long strings into parenthesised or multi-line expressions, added noqa E501 where appropriate, and adjusted tuple/type wrapping for readability. Affected files include engine/pyproject.toml and various modules under engine/src/stirling (agents, contracts, documents, services). No functional behavior changes.
2026-08-13 11:29:27 +02:00
Ludy d0223008f6 Merge branch 'main' into update_python_dep_20260812 2026-08-13 11:13:02 +02:00
Ludy87 da0ed4f7b2 tests: close SqliteVecStore and add runtime teardown
Add an autouse fixture (close_sqlite_stores) that tracks SqliteVecStore instances by monkeypatching __init__ and closes them with asyncio.run() to avoid leaked DB handles. Convert the runtime fixture to a yield-style fixture that closes app_runtime.documents on teardown. Update imports (asyncio, SqliteVecStore) and remove the now-redundant runtime construction from test_pdf_create. Fixes resource leaks in engine tests.
2026-08-12 17:16:40 +02:00
Ludy87 1e3f2312ee Update ai-engine.yml 2026-08-12 15:53:10 +02:00
Ludy87 27868fbe1c Upgrade ruff, add engine lint targets & style fixes
Add PY_FILES git pathspec and update .taskfiles/pre-commit to lint only tracked engine Python files; include engine/**/*.py in pre-commit patterns. Upgrade ruff to 0.16.2 (pyproject.toml + uv.lock) and add BLE to ruff select rules. Mark intentional bare excepts with noqa: BLE001. Apply numerous minor formatting and line-wrap cleanups (SQL, string joins, multi-line args) and small test/fixture tidy-ups. Temporarily lower pytest coverage gate to 20%.
2026-08-12 15:33:39 +02:00
Ludy87 e009ac3150 format 2026-08-12 11:54:20 +02:00
Ludy87 06c6bec7ce CI: add engine coverage & linting/style fixes
Add AI engine coverage reporting to CI (run tests with coverage, show in step summary, and upload artifact). Relax and align engine pre-commit package specifiers and update uv.lock. Increase ruff line-length and add per-file ignores; update Taskfile and pre-commit Taskfile to use engine ruff config. Add coverage entries to engine/.gitignore. Apply numerous non-functional Python style and formatting fixes across scripts and cucumber test steps (imports, f-strings, line breaks, noqa markers, single-line asserts) to satisfy linters—no behavioral changes intended.
2026-08-12 10:41:42 +02:00
44 changed files with 790 additions and 535 deletions
+8 -8
View File
@@ -10,10 +10,10 @@ adjusting the format.
Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# 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
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
""" # noqa: E501
import argparse
import glob
@@ -201,7 +201,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE:
has_differences = True
report.append(
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n" # noqa: E501
)
continue
@@ -223,11 +223,11 @@ def check_for_differences(reference_file, file_list, branch, actor):
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing." # noqa: E501
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed." # noqa: E501
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
@@ -248,7 +248,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to remove them:")
@@ -256,7 +256,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("")
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to add them:")
+26
View File
@@ -83,6 +83,32 @@ jobs:
});
}
- name: Run engine tests with coverage
id: engine-coverage
if: always()
run: task engine:test:coverage
- name: Add engine coverage to step summary
if: always() && steps.engine-coverage.outcome == 'success'
working-directory: engine
run: |
{
echo '## AI Engine coverage'
echo
echo '```text'
uv run --group engine-dev 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: |
+28 -5
View File
@@ -1,5 +1,14 @@
version: '3'
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'
'src/**/*.py'
':(exclude)**/tool_io.py'
':(exclude)**/tool_models.py'
tasks:
install:
desc: "Install engine runtime and development dependencies"
@@ -78,31 +87,31 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff check .
- uv run --locked --group pre-commit ruff check $(git ls-files {{.PY_FILES}})
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff check . --fix
- uv run --locked --group pre-commit ruff check $(git ls-files {{.PY_FILES}}) --fix
format:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff format .
- uv run --locked --group pre-commit ruff format $(git ls-files {{.PY_FILES}})
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff format . --diff
- uv run --locked --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) --diff
typecheck:
desc: "Run type checking"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev pyright . --warnings
- uv run --locked --group engine --group engine-dev pyright $(git ls-files {{.PY_FILES}}) --warnings
test:
desc: "Run tests"
@@ -110,6 +119,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 --locked --group engine --group engine-dev pytest tests
--cov=src/stirling
--cov-fail-under=20
--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 20
fix:
desc: "Auto-fix lint + format"
cmds:
+17 -9
View File
@@ -7,10 +7,13 @@ 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'
'engine/**/*.py'
':(exclude)engine/**/tool_io.py'
':(exclude)engine/**/tool_models.py'
SPELL_FILES: >-
'*.html'
'*.css'
@@ -80,7 +83,7 @@ tasks:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project engine --locked --group pre-commit
- uv sync --locked --project engine --group pre-commit
sources:
- engine/uv.lock
- engine/pyproject.toml
@@ -101,26 +104,31 @@ 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 --locked --project engine --group pre-commit ruff check $(git ls-files {{.PY_FILES}}) {{if .FIX}}--fix{{end}} --config engine/pyproject.toml
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 --locked --project engine --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) {{if .FIX}}{{else}}--check{{end}} --config engine/pyproject.toml
ruff-format-diff:
deps: [install]
cmds:
- uv run --locked --project engine --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) --diff --config engine/pyproject.toml
codespell:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
- uv run --locked --project engine --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
@@ -134,4 +142,4 @@ tasks:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/install_gitleaks.py
@@ -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
@@ -55,13 +55,13 @@ def resize_image(input_image_path, output_image_path, max_size=(16383, 16383)):
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}"
f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}" # noqa: E501
)
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,13 +1,14 @@
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)
mask = cv2.bitwise_not(mask)
kernel = np.ones((5,5),np.uint8)
kernel = np.ones((5, 5), np.uint8)
mask = cv2.dilate(mask, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
@@ -21,6 +22,7 @@ def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000,
return photo_boundaries
def estimate_background_color(image, sample_points=5):
h, w, _ = image.shape
points = [
@@ -37,6 +39,7 @@ def estimate_background_color(image, sample_points=5):
return np.median(colors, axis=0)
def auto_rotate(image, angle_threshold=1):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
@@ -61,8 +64,6 @@ def auto_rotate(image, angle_threshold=1):
return cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
def crop_borders(image, border_color, tolerance=30):
mask = cv2.inRange(image, border_color - tolerance, border_color + tolerance)
@@ -73,14 +74,31 @@ def crop_borders(image, border_color, tolerance=30):
largest_contour = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest_contour)
return image[y:y+h, x:x+w]
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,
):
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,
)
photo_boundaries = find_photo_boundaries(image, background_color, tolerance)
@@ -91,7 +109,7 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min
input_file_basename = os.path.splitext(os.path.basename(input_file))[0]
for idx, (x, y, w, h) in enumerate(photo_boundaries):
cropped_image = image[y:y+h, x:x+w]
cropped_image = image[y : y + h, x : x + w]
cropped_image = auto_rotate(cropped_image, angle_threshold)
# Remove the added border, but ensure we don't create an empty image
@@ -100,23 +118,60 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min
# Check if the cropped image is valid before saving
if cropped_image.size == 0 or cropped_image.shape[0] == 0 or cropped_image.shape[1] == 0:
print(f"Warning: Skipping empty image for region {idx+1}")
print(f"Warning: Skipping empty image for region {idx + 1}")
continue
output_path = os.path.join(output_directory, f"{input_file_basename}_{idx+1}.png")
output_path = os.path.join(output_directory, f"{input_file_basename}_{idx + 1}.png")
cv2.imwrite(output_path, cropped_image)
print(f"Saved {output_path}")
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(
"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).",
)
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,
)
+4
View File
@@ -46,3 +46,7 @@ logs/
# OS
.DS_Store
Thumbs.db
# Coverage
.coverage
coverage/
+9 -5
View File
@@ -27,11 +27,11 @@ engine = [
# Type checking, testing, model generation, and formatting tools for the engine.
engine-dev = [
"anyio>=4.14.2",
"datamodel-code-generator[ruff]==0.64.0",
"datamodel-code-generator[ruff]>=0.64.0",
"pyright>=1.1.411",
"pytest>=9.1.1",
"pytest-cov>=7.0.0",
"referencing>=0.37.0",
"ruff==0.15.5",
]
# Dependencies for the Cucumber/Python integration test suite.
cucumber = [
@@ -65,9 +65,9 @@ updater-signatures = [
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
"codespell==2.4.3",
"ruff==0.15.5",
"tomli-w==1.2.0",
"codespell>=2.4.3",
"ruff>=0.16.2",
"tomli-w>=1.2.0",
]
[build-system]
@@ -99,6 +99,10 @@ select = [
"BLE", # flake8-blind-except: flags bare `except Exception`
]
[tool.ruff.lint.per-file-ignores]
"testing/**/*.py" = ["N803", "BLE001", "E501"]
"*split_photos.py" = ["E501", "N806"]
[tool.ruff.lint.isort]
known-first-party = ["stirling", "tests"]
+35
View File
@@ -0,0 +1,35 @@
"""Fail when any measured source file falls below the required coverage."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("report", type=Path)
parser.add_argument("--minimum", type=float, default=90.0)
args = parser.parse_args()
data = json.loads(args.report.read_text(encoding="utf-8"))
failures: list[tuple[str, float]] = []
for filename, details in data["files"].items():
coverage = float(details["summary"]["percent_covered"])
if coverage < args.minimum:
failures.append((filename, coverage))
if failures:
print(f"Per-file coverage below {args.minimum:.1f}%:", file=sys.stderr)
for filename, coverage in sorted(failures):
print(f" {coverage:.1f}% {filename}", file=sys.stderr)
return 1
print(f"Per-file coverage: every file is at least {args.minimum:.1f}%.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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." # noqa: E501
),
)
@@ -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.)." # noqa: E501
),
)
+1 -2
View File
@@ -344,8 +344,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" # noqa: E501
if unavailable_operations
else ""
)
+1 -1
View File
@@ -218,7 +218,7 @@ async def apply_config(request: ConfigPushRequest, http_request: Request) -> Con
save_config(request)
# Claim the stamp we just wrote so this worker's watcher does not rebuild for it.
app.state.config_cache_stamp = cache_stamp()
except Exception: # noqa: BLE001 - best-effort persist, never fail the applied push
except Exception:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append(
"Config applied on this worker but could not be persisted; it will not survive an"
+1 -2
View File
@@ -110,8 +110,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.", # noqa: E501
)
unauditable_pages: list[int] = Field(
default_factory=list,
@@ -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" # noqa: E501
)
await cur.execute(
"""
+1 -1
View File
@@ -139,7 +139,7 @@ class DocumentService:
try:
results = await self._store.search(col_name, query_embedding, k, principals)
all_results.extend(results)
except Exception: # noqa: BLE001 - any backend error on one collection should not stop the others
except Exception:
logger.warning(
"Skipping collection %s during cross-collection search",
col_name,
@@ -366,8 +366,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 (?, ?, ?, ?, ?)", # noqa: E501
[(collection, owner_id, p.page_number, p.text, p.char_count) for p in pages],
)
self._conn.commit()
@@ -188,8 +188,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." # noqa: E501
),
)
]
-7
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import json
import pytest
from conftest import build_app_settings
from pydantic_ai.models.test import TestModel
from pydantic_ai.profiles import ModelProfile
@@ -45,7 +44,6 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
_NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
@@ -53,11 +51,6 @@ _NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
# ── Fixtures ──────────────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def runtime() -> AppRuntime:
return build_runtime(build_app_settings())
@pytest.fixture
def agent(runtime: AppRuntime) -> PdfCreateAgent:
return PdfCreateAgent(runtime)
+21 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
import pytest
from stirling.config import AppSettings, DocumentsBackend, load_settings
from stirling.documents import SqliteVecStore
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
@@ -17,6 +19,21 @@ def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
@pytest.fixture(autouse=True)
def close_sqlite_stores(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
stores: list[SqliteVecStore] = []
original_init = SqliteVecStore.__init__
def tracked_init(store: SqliteVecStore, db_path: str | Path) -> None:
original_init(store, db_path)
stores.append(store)
monkeypatch.setattr(SqliteVecStore, "__init__", tracked_init)
yield
for store in stores:
asyncio.run(store.close())
def build_app_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
@@ -57,5 +74,7 @@ def app_settings() -> AppSettings:
@pytest.fixture
def runtime(app_settings: AppSettings) -> AppRuntime:
return build_runtime(app_settings)
def runtime(app_settings: AppSettings) -> Iterator[AppRuntime]:
app_runtime = build_runtime(app_settings)
yield app_runtime
asyncio.run(app_runtime.documents.close())
+75 -25
View File
@@ -262,6 +262,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"
@@ -430,8 +454,8 @@ engine-dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "referencing" },
{ name = "ruff" },
]
pre-commit = [
{ name = "codespell" },
@@ -485,16 +509,16 @@ engine = [
]
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.64.0" },
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "referencing", specifier = ">=0.37.0" },
{ name = "ruff", specifier = "==0.15.5" },
]
pre-commit = [
{ name = "codespell", specifier = "==2.4.3" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "codespell", specifier = ">=2.4.3" },
{ name = "ruff", specifier = ">=0.16.2" },
{ name = "tomli-w", specifier = ">=1.2.0" },
]
tools = [
{ name = "deep-translator", specifier = ">=1.11.4" },
@@ -1260,6 +1284,32 @@ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1424,27 +1474,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.5"
version = "0.16.2"
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/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
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/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
]
[[package]]
@@ -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",
+4 -4
View File
@@ -10,9 +10,9 @@ import json
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -152,7 +152,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600, model="g
print(f"\n[{i}/{total}] Translating {batch_file}...")
# Always pass API key since it's required
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}'
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}' # noqa: E501
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
@@ -223,7 +223,7 @@ def apply_translations(merged_file, language_code):
"""Apply merged translations to the language file."""
print(f"\n📝 Applying translations to {language_code}...")
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}"
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}" # noqa: E501
if not run_command(cmd):
print("✗ Failed to apply translations")
@@ -388,7 +388,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
+3 -3
View File
@@ -98,7 +98,7 @@ CRITICAL RULES - MUST FOLLOW EXACTLY:
- Do not remove any part of the original meaning
- Keep the same level of detail
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object.""" # noqa: E501
def _record_usage(self, response) -> None:
"""Accumulate token usage/cost and print a per-batch line."""
@@ -182,7 +182,7 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
placeholder_pattern = r"\{[^}]+\}|\{\{[^}]+\}\}"
for key in original.keys():
for key in original:
if key not in translated:
continue
@@ -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
+3 -3
View File
@@ -79,7 +79,7 @@ def get_language_completion(locales_dir: Path, language: str) -> float | None:
return (translated / total * 100) if total > 0 else 0.0
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not calculate completion for {language}: {e}")
return None
@@ -144,8 +144,8 @@ def translate_language(
except subprocess.TimeoutExpired:
safe_print(f"[{language}] ✗ Timeout exceeded")
return (language, False, "Timeout exceeded")
except Exception as e:
safe_print(f"[{language}] ✗ Error: {str(e)}")
except Exception as e: # noqa: BLE001
safe_print(f"[{language}] ✗ Error: {e!s}")
return (language, False, str(e))
+2 -2
View File
@@ -38,7 +38,7 @@ class CompactTranslationExtractor:
except FileNotFoundError:
print(f"Error: File not found: {file_path}", file=sys.stderr)
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML file {file_path}: {e}", file=sys.stderr)
sys.exit(1)
@@ -51,7 +51,7 @@ class CompactTranslationExtractor:
with open(self.ignore_file, "rb") as f:
ignore_data = tomllib.load(f)
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
except Exception as e:
except Exception as e: # noqa: BLE001
print(
f"Warning: Could not load ignore file {self.ignore_file}: {e}",
file=sys.stderr,
+2 -2
View File
@@ -28,7 +28,7 @@ class TOMLBeautifier:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML in {file_path}: {e}")
sys.exit(1)
@@ -172,7 +172,7 @@ class TOMLBeautifier:
def get_key_order(obj: dict, path: str = "") -> list[str]:
keys = []
for key in obj.keys():
for key in obj:
new_path = f"{path}.{key}" if path else key
keys.append(new_path)
if isinstance(obj[key], dict):
+3 -3
View File
@@ -33,7 +33,7 @@ def get_line_context(file_path, line_num, context_lines=3):
context.append(f"{marker}{i + 1:4d}: {lines[i].rstrip()}")
return "\n".join(context)
except Exception as e:
except Exception as e: # noqa: BLE001
return f"Could not read context: {e}"
@@ -56,7 +56,7 @@ def get_character_context(file_path, char_pos, context_chars=100):
"after": after,
"display": f"{before}[{error_char}]{after}",
}
except Exception:
except Exception: # noqa: BLE001
return None
@@ -90,7 +90,7 @@ def validate_toml_file(file_path):
result["valid"] = True
result["entry_count"] = count_keys(data)
except Exception as e:
except Exception as e: # noqa: BLE001
error_msg = str(e)
result["error"] = error_msg
+3 -3
View File
@@ -31,7 +31,7 @@ class TranslationAnalyzer:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid file {file_path}: {e}")
sys.exit(1)
@@ -51,7 +51,7 @@ class TranslationAnalyzer:
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 +282,7 @@ def main():
print("\nBottom 5 Languages Needing Attention:")
for result in sorted_by_completion[-5:]:
print(
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)"
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)" # noqa: E501
)
+4 -4
View File
@@ -39,7 +39,7 @@ class TranslationMerger:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid file {file_path}: {e}")
sys.exit(1)
@@ -64,7 +64,7 @@ class TranslationMerger:
# Convert to sets for faster lookup
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
return {}
@@ -264,7 +264,7 @@ class TranslationMerger:
self._set_nested_value(target_data, key, translation)
applied_count += 1
except Exception as e:
except Exception as e: # noqa: BLE001
errors.append(f"Error setting {key}: {e}")
if applied_count > 0:
@@ -362,7 +362,7 @@ def main():
parser.add_argument(
"language",
nargs="?",
help="Target language code (e.g., fr-FR). If omitted, add-missing and remove-unused run for all locales except en-US.",
help="Target language code (e.g., fr-FR). If omitted, add-missing and remove-unused run for all locales except en-US.", # noqa: E501
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
@@ -37,8 +37,8 @@ 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:
return False, f"Error reading file: {str(e)}"
except Exception as e: # noqa: BLE001
return False, f"Error reading file: {e!s}"
def validate_structure(en_us_keys: set[str], lang_keys: set[str], lang_code: str) -> dict:
@@ -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
+35 -18
View File
@@ -5,8 +5,8 @@ import sys
import requests
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "steps"))
import job_support # noqa: E402
import parallel_support # noqa: E402
import job_support
import parallel_support
_BASE_URL = "http://localhost:8080"
_CONTAINER_NAME = os.environ.get("TEST_CONTAINER_NAME", "")
@@ -17,12 +17,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"})
@@ -61,7 +74,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:
@@ -74,7 +89,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:]
@@ -104,7 +121,12 @@ def _check_policies_available():
resp = requests.post(
f"{_BASE_URL}/api/v1/sources",
headers={"X-API-KEY": "123456789", "Content-Type": "application/json"},
json={"name": "policies-probe", "type": "webhook", "options": {}, "enabled": True},
json={
"name": "policies-probe",
"type": "webhook",
"options": {},
"enabled": True,
},
timeout=10,
)
if resp.status_code != 200:
@@ -214,9 +236,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:
@@ -256,10 +276,7 @@ def _cleanup_async_job_files():
)
return
if response.status_code != 200:
print(
f"\n[CLEANUP] Async job cleanup returned {response.status_code}: "
f"{response.text[:200]}"
)
print(f"\n[CLEANUP] Async job cleanup returned {response.status_code}: {response.text[:200]}")
return
try:
summary = response.json()
@@ -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}"')
@@ -440,8 +415,7 @@ def step_json_top_field_equals(context, field, expected):
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}"
f"Expected JSON field '{field}' == '{expected}' but got '{actual}'. Full response: {data}"
)
@@ -459,15 +433,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 +448,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 +457,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}"}
@@ -75,8 +73,7 @@ def _resolve_folder_id(context, ref):
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)}"
f"No folder named {name!r} stashed; available: {list(context.folders_by_name)}"
)
return context.folders_by_name[name]
return ref
@@ -120,9 +117,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 +140,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,14 +151,10 @@ 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}"
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 +244,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 +258,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 +271,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,17 +305,13 @@ 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}"')
@@ -340,34 +319,27 @@ 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}"
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 +353,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: "
@@ -1,4 +1,5 @@
"""Steps for the async job API. DELETE is a cancel, so it 400s once the job finishes."""
import time
import requests
@@ -21,27 +22,25 @@ def step_wait_for_job(context):
while time.time() < deadline:
response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
)
assert response.status_code == 200, (
f"Job status returned {response.status_code}: {response.text}"
headers=API_HEADERS,
timeout=30,
)
assert response.status_code == 200, f"Job status returned {response.status_code}: {response.text}"
context.response = response
payload = response.json()
if payload.get("complete"):
context.job_status = payload
return
time.sleep(0.2)
raise AssertionError(
f"Job {context.job_id} did not complete within {POLL_TIMEOUT_SECONDS}s"
)
raise AssertionError(f"Job {context.job_id} did not complete within {POLL_TIMEOUT_SECONDS}s")
@when("I request the job result")
def step_request_job_result(context):
context.response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result",
headers=API_HEADERS, timeout=60,
headers=API_HEADERS,
timeout=60,
)
@@ -49,7 +48,8 @@ def step_request_job_result(context):
def step_request_job_result_files(context):
context.response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}/result/files",
headers=API_HEADERS, timeout=60,
headers=API_HEADERS,
timeout=60,
)
files = context.response.json().get("files") or []
context.job_files = files
@@ -62,7 +62,8 @@ def step_download_job_file(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
context.response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=60,
headers=API_HEADERS,
timeout=60,
)
@@ -71,7 +72,8 @@ def step_job_file_metadata(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
context.response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}/metadata",
headers=API_HEADERS, timeout=60,
headers=API_HEADERS,
timeout=60,
)
@@ -79,7 +81,8 @@ def step_job_file_metadata(context):
def step_cancel_job(context):
context.response = requests.delete(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
headers=API_HEADERS,
timeout=30,
)
@@ -127,8 +130,7 @@ def step_check_cleanup_idempotent(context):
removed = context.cleanup_summary.get("jobsRemoved")
deleted = context.cleanup_summary.get("filesDeleted")
assert removed == 0 and deleted == 0, (
"A repeat cleanup still found work to do, so the first pass did not fully clean up: "
f"{context.cleanup_summary}"
f"A repeat cleanup still found work to do, so the first pass did not fully clean up: {context.cleanup_summary}"
)
@@ -136,11 +138,11 @@ def step_check_cleanup_idempotent(context):
def step_check_job_gone(context):
response = requests.get(
f"{BASE_URL}/api/v1/general/job/{context.job_id}",
headers=API_HEADERS, timeout=30,
headers=API_HEADERS,
timeout=30,
)
assert response.status_code == 404, (
f"Job {context.job_id} still exists after cleanup: "
f"{response.status_code} {response.text[:200]}"
f"Job {context.job_id} still exists after cleanup: {response.status_code} {response.text[:200]}"
)
@@ -149,11 +151,11 @@ def step_check_job_file_gone(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=30,
headers=API_HEADERS,
timeout=30,
)
assert response.status_code == 404, (
f"File {context.job_file_id} still downloadable after cleanup: "
f"{response.status_code} {response.text[:200]}"
f"File {context.job_file_id} still downloadable after cleanup: {response.status_code} {response.text[:200]}"
)
@@ -162,10 +164,10 @@ def step_check_job_file_still_there(context):
assert getattr(context, "job_file_id", None), "No fileId captured from the result file list"
response = requests.get(
f"{BASE_URL}/api/v1/general/files/{context.job_file_id}",
headers=API_HEADERS, timeout=60,
headers=API_HEADERS,
timeout=60,
)
assert response.status_code == 200, (
f"File {context.job_file_id} was not retrievable a second time: "
f"{response.status_code} {response.text[:200]}"
f"File {context.job_file_id} was not retrievable a second time: {response.status_code} {response.text[:200]}"
)
assert len(response.content) > 0, "Second download returned an empty body"
@@ -4,6 +4,7 @@ Support module, not a step module: behave execs everything under features/steps
step definitions, so anything environment.py needs to import has to live apart from
the @when/@then decorators or they would register twice.
"""
import requests
BASE_URL = "http://localhost:8080"
@@ -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")
@@ -35,7 +38,18 @@ 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]
[
"docker",
"exec",
PG,
"psql",
"-U",
"stirling",
"-d",
"stirling",
"-tAc",
query,
]
)
assert rc == 0, f"psql failed: {err.strip() or out.strip()}"
return out.strip()
@@ -48,8 +62,13 @@ def _psql_int(query):
def _network():
rc, out, _ = _sh(
["docker", "inspect", "-f",
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}", NODES[0]]
[
"docker",
"inspect",
"-f",
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}",
NODES[0],
]
)
return out.strip() or "compose_stirling-multinode"
@@ -89,18 +108,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 +134,11 @@ 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 +148,57 @@ 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 +207,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 +216,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 +229,28 @@ 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 +297,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 +318,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 +334,12 @@ 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}"
@@ -336,8 +410,8 @@ def step_files_processed(context, seconds):
break
time.sleep(3)
assert remaining == 0, (
f"{remaining} of {len(context._dropped)} dropped files were still unprocessed after "
f"{seconds}s")
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 +422,30 @@ 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 +457,11 @@ 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 +485,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 +494,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 +563,14 @@ 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 +578,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 +593,12 @@ 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 +641,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 +679,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 +705,13 @@ 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,8 +1,7 @@
"""Concurrency steps, usable either before the request or after it."""
from behave import given, then, when
import parallel_support
from behave import given, then, when
def _set_repeat(context, count, decoy=False):
@@ -2,7 +2,6 @@
import io
import json as json_module
import os
import re
import sys
import zipfile
@@ -59,11 +58,7 @@ def _normalize_name(name):
def _strip_volatile(value):
"""Drop keys whose values legitimately differ between two identical requests."""
if isinstance(value, dict):
return {
k: _strip_volatile(v)
for k, v in sorted(value.items())
if not _VOLATILE_KEY_RE.match(k)
}
return {k: _strip_volatile(v) for k, v in sorted(value.items()) if not _VOLATILE_KEY_RE.match(k)}
if isinstance(value, list):
return [_strip_volatile(v) for v in value]
if isinstance(value, str):
@@ -131,7 +126,6 @@ def fingerprint(response):
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip()
parts = {"status": response.status_code, "content_type": content_type, "size": len(body)}
if "json" in content_type:
try:
parts["json"] = _strip_volatile(json_module.loads(body.decode("utf-8")))
@@ -152,11 +146,7 @@ def fingerprint(response):
def differing_keys(baseline, other):
return {
key
for key in set(baseline) | set(other)
if key != "size" and baseline.get(key) != other.get(key)
}
return {key for key in set(baseline) | set(other) if key != "size" and baseline.get(key) != other.get(key)}
def size_differs(baseline, other):
@@ -168,9 +158,7 @@ def compare(baseline, other, ignore=frozenset(), ignore_size=False):
"""Return a list of human-readable differences between two fingerprints."""
diffs = []
for key in sorted(differing_keys(baseline, other) - set(ignore)):
diffs.append(
f"{key}: baseline={_short(baseline.get(key))} parallel={_short(other.get(key))}"
)
diffs.append(f"{key}: baseline={_short(baseline.get(key))} parallel={_short(other.get(key))}")
if not ignore_size and size_differs(baseline, other):
diffs.append(
f"size: baseline={baseline.get('size', 0)} parallel={other.get('size', 0)} "
@@ -212,9 +200,7 @@ def build_decoy_spec(spec):
width, height = float(box.width), float(box.height)
overlay_buffer = io.BytesIO()
overlay_canvas = canvas.Canvas(overlay_buffer, pagesize=(width, height))
overlay_canvas.drawString(
20, max(20.0, height - 20), f"DECOY-MARKER-{index}-do-not-mix"
)
overlay_canvas.drawString(20, max(20.0, height - 20), f"DECOY-MARKER-{index}-do-not-mix")
overlay_canvas.showPage()
overlay_canvas.save()
overlay_buffer.seek(0)
@@ -261,16 +247,12 @@ def validate(context, url, spec, headers, baseline, label, timeout=300):
baseline_fp = fingerprint(baseline)
noise, noisy_size = frozenset(), False
failures = _collect_failures(
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
)
failures = _collect_failures(main_results, decoy_results, baseline_fp, repeat, noise, noisy_size)
if failures:
# Some endpoints are inherently nondeterministic (embedded ids, timestamps,
# deliberate randomness). Re-run sequentially to tell that apart from a real bug.
noise, noisy_size = _probe_noise(url, spec, headers, baseline_fp, timeout)
failures = _collect_failures(
main_results, decoy_results, baseline_fp, repeat, noise, noisy_size
)
failures = _collect_failures(main_results, decoy_results, baseline_fp, repeat, noise, noisy_size)
VALIDATIONS.append(
{
@@ -303,12 +285,9 @@ def _collect_failures(main_results, decoy_results, baseline_fp, repeat, noise, n
diffs = compare(baseline_fp, actual_fp, noise, noisy_size)
if not diffs:
continue
if decoy_fp is not None and not compare(
decoy_fp, actual_fp, noise, noisy_size
):
if decoy_fp is not None and not compare(decoy_fp, actual_fp, noise, noisy_size):
failures.append(
f"copy {index + 1}/{repeat} returned the CONCURRENT DECOY REQUEST'S response "
f"(cross-request bleed)"
f"copy {index + 1}/{repeat} returned the CONCURRENT DECOY REQUEST'S response (cross-request bleed)"
)
else:
failures.append(f"copy {index + 1}/{repeat} diverged: " + "; ".join(diffs))
@@ -374,7 +353,7 @@ def validate_get(context, url, params, headers, baseline, label, timeout=60):
diffs = compare(
baseline_fp,
fingerprint(response),
noise,
noise,
noisy_size,
)
if diffs:
@@ -387,11 +366,7 @@ def validate_get(context, url, params, headers, baseline, label, timeout=60):
probes = []
for _ in range(NOISE_PROBE_SAMPLES):
try:
probes.append(
fingerprint(
requests.get(url, params=params, headers=headers, timeout=timeout)
)
)
probes.append(fingerprint(requests.get(url, params=params, headers=headers, timeout=timeout)))
except Exception:
break
noise, noisy_size = _noise_from_samples(baseline_fp, probes)
@@ -408,8 +383,7 @@ def validate_get(context, url, params, headers, baseline, label, timeout=60):
)
if failures:
raise AssertionError(
f"Parallel consistency failed for GET {label} at concurrency {repeat}.\n - "
+ "\n - ".join(failures)
f"Parallel consistency failed for GET {label} at concurrency {repeat}.\n - " + "\n - ".join(failures)
)
@@ -1,21 +1,21 @@
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 parallel_support
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
import parallel_support
API_HEADERS = {"X-API-KEY": "123456789"}
@@ -101,15 +101,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")
@@ -124,13 +120,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
@@ -145,9 +137,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
@@ -163,9 +153,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()
@@ -208,9 +196,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
@@ -498,9 +484,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)
@@ -529,10 +513,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")
@@ -548,7 +529,11 @@ def step_pdf_has_qr_split_marker(context, page_num):
can = canvas.Canvas(packet, pagesize=letter)
w, h = letter
can.drawImage(
ImageReader(qr_bytes), (w - 100) / 2, (h - 100) / 2, width=100, height=100
ImageReader(qr_bytes),
(w - 100) / 2,
(h - 100) / 2,
width=100,
height=100,
)
can.showPage()
can.save()
@@ -670,9 +655,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}")
@@ -695,20 +680,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: {e!s}. 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: {e!s}. 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}"')
@@ -716,9 +697,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}"')
@@ -726,9 +705,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}"')
@@ -741,9 +720,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')
@@ -758,9 +737,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")
@@ -768,61 +745,51 @@ 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
):
def step_check_json_list_entry(context, identifier_key, identifier_value, 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)}"
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)}"
)
break
else:
raise AssertionError(
f"No entry with {identifier_key} as {identifier_value} found"
)
raise AssertionError(f"No entry with {identifier_key} as {identifier_value} 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"}
@@ -54,7 +54,10 @@ def step_deliver_with_signature(context, payload, signature):
def step_deliver_to_id(context, payload, webhook_id):
context.webhook_response = requests.post(
f"{BASE_URL}/api/v1/webhooks/{webhook_id}",
headers={"Content-Type": "application/pdf", "X-Stirling-Signature": "sha256=00"},
headers={
"Content-Type": "application/pdf",
"X-Stirling-Signature": "sha256=00",
},
data=payload.encode(),
timeout=15,
)
+67 -26
View File
@@ -57,9 +57,11 @@ def create_clean_invoice():
pdf.cell(0, 8, "Grand Total: $8,195.00", new_x="LMARGIN", new_y="NEXT")
pdf.ln(3)
pdf.cell(
0, 8,
0,
8,
"Breakdown: $6,000.00 + $1,000.00 + $450.00 = $7,450.00",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/clean_invoice.pdf")
@@ -87,9 +89,11 @@ def create_tally_error():
pdf.ln(5)
pdf.cell(
0, 8,
0,
8,
"Total Q1 spend: $68,000 + $66,000 + $71,200 = $205,200",
new_x="LMARGIN", new_y="NEXT",
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output("testing/ledger/tally_error.pdf")
@@ -156,12 +160,32 @@ 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 +193,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 +225,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 +265,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 +314,7 @@ def create_statement_errors():
if __name__ == "__main__":
import os
os.makedirs("testing/ledger", exist_ok=True)
print("Generating test PDFs:")
create_clean_invoice()