mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11aef9eb7a | ||
|
|
193a5231a0 | ||
|
|
5efefa51d7 | ||
|
|
e4bb1b4b9f | ||
|
|
444f6ea01f | ||
|
|
580ad97321 | ||
|
|
639e3652b0 | ||
|
|
b39df8ca14 | ||
|
|
84cb0d8725 | ||
|
|
d0223008f6 | ||
|
|
da0ed4f7b2 | ||
|
|
1e3f2312ee | ||
|
|
27868fbe1c | ||
|
|
e009ac3150 | ||
|
|
06c6bec7ce |
@@ -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:")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -46,3 +46,7 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Generated
+75
-25
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class CompactTranslationExtractor:
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Error: Invalid TOML file {file_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -51,7 +51,7 @@ class CompactTranslationExtractor:
|
||||
with open(self.ignore_file, "rb") as f:
|
||||
ignore_data = tomllib.load(f)
|
||||
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"Warning: Could not load ignore file {self.ignore_file}: {e}",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -28,7 +28,7 @@ class TOMLBeautifier:
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Error: Invalid TOML in {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -33,7 +33,7 @@ def get_line_context(file_path, line_num, context_lines=3):
|
||||
context.append(f"{marker}{i + 1:4d}: {lines[i].rstrip()}")
|
||||
|
||||
return "\n".join(context)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"Could not read context: {e}"
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def get_character_context(file_path, char_pos, context_chars=100):
|
||||
"after": after,
|
||||
"display": f"{before}[{error_char}]{after}",
|
||||
}
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ def validate_toml_file(file_path):
|
||||
result["valid"] = True
|
||||
result["entry_count"] = count_keys(data)
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
error_msg = str(e)
|
||||
result["error"] = error_msg
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class TranslationAnalyzer:
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Error: Invalid file {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user