diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c188e697..eba9c5c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,9 @@ permissions: contents: write id-token: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + concurrency: group: publication-${{ github.workflow }}-${{ inputs.release_tag || github.ref_name }} cancel-in-progress: true @@ -62,7 +65,7 @@ jobs: echo "Working tree is clean" - name: Set up uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -242,7 +245,7 @@ jobs: - name: Store PyPI distributions if: matrix.python-version == '3.14' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pypi-dist path: publish-dist/ @@ -251,7 +254,7 @@ jobs: - name: Store GitHub release assets if: matrix.python-version == '3.14' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-assets path: release-assets-dist/ @@ -264,7 +267,7 @@ jobs: timeout-minutes: 30 if: github.event_name == 'workflow_dispatch' && inputs.publish_to_testpypi steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pypi-dist path: dist/ @@ -295,7 +298,7 @@ jobs: ref: ${{ inputs.release_tag }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} @@ -383,7 +386,7 @@ jobs: - name: Make scripts executable run: chmod +x ./scripts/*.sh - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-assets path: dist/ @@ -445,7 +448,7 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} @@ -547,7 +550,7 @@ jobs: # available on GitHub Actions runners by default. # GitHub Actions runners ship with Python 3.12 as their system Python as of 2025. - name: Set up Python 3.12 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89e7a05e..623dfb6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + concurrency: group: test-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -34,7 +37,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 179d9aba..ef94d88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: CHANGELOG -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [changelog, release notes, version history, breaking changes, migration, fixed, what's new] questions: ["what changed in version X?", "what are the breaking changes?", "what was fixed in the latest release?", "what is the release history?"] @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.164.0] - 2026-04-23 ### Changed - **Release publication now stages PyPI uploads separately from GitHub Release assets.** The @@ -22,6 +23,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 keeping the `.sha256` receipt in the GitHub Release asset path, and the maintainer runbook now documents rerunning `workflow_dispatch` against an existing tag to recover a partial release without moving or recreating the tag. +- **GitHub Actions now opt into Node 24 and pin the Node 24-capable immutable action releases.** + The `Test` and `Build and Publish` workflows now set + `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true`, move `astral-sh/setup-uv` to its + official `v8.1.0` commit pin, and update `actions/setup-python`, + `actions/upload-artifact`, and `actions/download-artifact` to their current + Node 24-native immutable releases. +- **Parser-only facades now expose the zero-dependency helpers that actually work without Babel.** + `ftllexengine`, `ftllexengine.runtime`, and `ftllexengine.localization` now keep + `CacheConfig`, `FluentNumber`, `fluent_function`, `make_fluent_number`, + `PathResourceLoader`, `FallbackInfo`, `ResourceLoadResult`, `LoadSummary`, and + `CacheAuditLogEntry` importable in parser-only installs while gating only the + Babel-backed bundle/orchestration facades behind the runtime extra. +- **Public locale-aware entry points now reject unknown locales instead of silently formatting as `en_US`.** + `FluentBundle`, `FluentLocalization`, `number_format()`, `datetime_format()`, and + `currency_format()` now validate locale identifiers against Babel/CLDR up front so + a misconfigured locale cannot claim one public value while rendering with another. +- **`clear_module_caches()` now validates selector names and no longer masks internal import failures as missing Babel.** + Unknown cache component names raise `ValueError`, parser-only skips are driven by + Babel availability instead of broad `ImportError` swallowing, and broken runtime + imports now surface their real exception path. +- **Parser-only optional facades now behave like truly absent names during feature probing.** + Babel-backed names such as `ftllexengine.FluentBundle`, + `ftllexengine.localization.FluentLocalization`, and parser-only-hidden + `ftllexengine.runtime.number_format()` now return `False` from `hasattr()` and + honor `getattr(..., default)` instead of raising during routine capability + checks, while direct access still carries explicit runtime-install guidance. +- **The Babel availability gate no longer rewrites broken Babel imports into misleading “install Babel” errors.** + `core.babel_compat._check_babel_available()` now treats only a genuinely missing + top-level `babel` package as parser-only mode; internal `ImportError` failures + from a broken Babel install now surface unchanged for diagnosis instead of being + collapsed into `BabelImportError`. +- **Parser-only `ftllexengine.runtime` no longer advertises locale-formatting helpers that cannot run without Babel.** + `create_default_registry()`, `get_shared_registry()`, `number_format()`, + `datetime_format()`, `currency_format()`, and `select_plural_category()` are + now gated with the same full-runtime boundary as the bundle classes instead of + remaining importable-but-immediately-failing in parser-only installs. +- **Documentation now uses consistent parser-only vs full-runtime terminology and fixes Babel requirement scope.** + README and guide pages now distinguish the two install modes explicitly, + document that unknown locales fail fast at public formatting boundaries, and + stop implying that all ISO helpers require Babel when + `get_currency_decimal_digits()` works from embedded tables. +- **Core and runtime reference docs now match the current localization and cache contracts.** + The reference set now describes `FluentLocalization` as eager-load plus + demand-driven bundle materialization, documents the exact + `clear_module_caches()` selector names, and correctly describes + `CacheAuditLogEntry` as the public alias of `WriteLogEntry` rather than a + separate dataclass. +- **Quick-reference and example index docs now steer users toward the actual default behavior.** + The quick reference labels full-runtime vs parser-only install commands, + removes an unnecessary `strict=False` from the multi-locale example, and the + examples index now calls out the parser-only helper facades shown by + `examples/parser_only.py`. +- **Locale, contributing, release, and fuzzing docs now match the shipped workflows more precisely.** + The locale guide now documents `get_system_locale()` as a fallback-returning + helper instead of implying a default-path `ValueError`, contributing docs no + longer claim the examples rely on local stubs, the release runbook now keeps + pre-flight worktrees detached until the gates pass, explicitly distinguishes + bootstrap-path staged diffs from the full PR diff against `origin/main`, and + the Atheris docs now state clearly that `--list` inspects stored crashes and + findings instead of enumerating target names. +- **Shipped examples now assert their own contracts, and the example runner enforces those contracts.** + `examples/parser_only.py` now demonstrates the real top-level `validate_resource()` + surface plus warning-only versus invalid validation semantics, `examples/ftl_linter.py` + now detects attribute-only no-value messages and undefined term references as advertised, + and `scripts/run_examples.py` now rejects examples that exit `0` but miss their registered + output-contract markers. +- **Diagnostics docs now describe the real structural parser-annotation contract.** + `ParserAnnotation` is now a documented public diagnostics export, and the + diagnostics reference plus regression tests now bind `ValidationResult.annotations` + to `tuple[ParserAnnotation, ...]` instead of implying a single concrete `Annotation` + implementation. +- **Additional god-files are now split, and architecture budgets reach beyond `src/`.** + `introspection.iso` now delegates lookup, validation, and cache clearing into + focused modules instead of carrying all ISO logic in one file, the + `runtime.bundle` property test monolith is now partitioned into focused test + modules, and `tests/test_architecture_contract.py` now enforces explicit line + budgets for the previously ungoverned test, fuzz, and script hotspots as well + as the remaining large internal utility modules. ## [0.163.0] - 2026-04-22 @@ -6897,6 +6976,7 @@ Both validators are re-exported from `ftllexengine.introspection` and the root [0.29.0]: https://github.com/resoltico/ftllexengine/releases/tag/v0.29.0 [0.28.1]: https://github.com/resoltico/ftllexengine/releases/tag/v0.28.1 [0.28.0]: https://github.com/resoltico/ftllexengine/releases/tag/v0.28.0 -[Unreleased]: https://github.com/resoltico/FTLLexEngine/compare/v0.163.0...HEAD +[Unreleased]: https://github.com/resoltico/FTLLexEngine/compare/v0.164.0...HEAD +[0.164.0]: https://github.com/resoltico/FTLLexEngine/compare/v0.163.0...v0.164.0 [0.163.0]: https://github.com/resoltico/FTLLexEngine/compare/v0.162.0...v0.163.0 [0.162.0]: https://github.com/resoltico/FTLLexEngine/compare/v0.161.0...v0.162.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da2a5494..b410e7e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: CONTRIBUTING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [contributing, development, uv, lint, test, fuzz, benchmark, release, virtualenv] questions: ["how do I set up development?", "how do I run lint and tests?", "how do I work on fuzzing?", "how do I prepare a release?"] @@ -56,7 +56,8 @@ Useful variants: - `./scripts/benchmark.sh` - `./scripts/fuzz_hypofuzz.sh` - `./scripts/fuzz_hypofuzz.sh --deep --time 300` -- `./scripts/fuzz_atheris.sh --list` +- `./scripts/fuzz_atheris.sh numbers --time 60` +- `./scripts/fuzz_atheris.sh --list` to inspect stored crashes and finding artifacts ## Documentation Work @@ -77,7 +78,7 @@ Expectations: ## Type Checking Examples -The `examples/` directory has its own `mypy.ini` and local stubs. +The `examples/` directory has its own `mypy.ini` and does not rely on local stub overlays. ```bash uv run mypy --config-file examples/mypy.ini examples diff --git a/PATENTS.md b/PATENTS.md index 3f2412a3..8618daa0 100644 --- a/PATENTS.md +++ b/PATENTS.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: LEGAL updated: "2026-04-22" route: diff --git a/README.md b/README.md index ea8e2364..e65ade8f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ assert errors == () assert result == "500 bags of Ethiopian coffee" ``` +Unknown locales raise `ValueError` on `FluentBundle`, +`FluentLocalization`, `number_format()`, `datetime_format()`, and +`currency_format()` rather than silently formatting with a fallback locale. + > `use_isolating=False` removes Unicode bidi isolation markers from output, making strings suitable for direct comparison and logging. The default `use_isolating=True` wraps each placeable in U+2068/U+2069 markers for correct bidirectional text rendering in UI contexts. **Parse user input back to Python types:** @@ -110,6 +114,9 @@ Or with pip: pip install ftllexengine[babel] ``` +This is the **full runtime** install: locale-aware formatting, localization orchestration, +bidirectional parsing, and Babel-backed ISO helpers. + **Requirements**: Python >= 3.13 | Babel >= 2.18
@@ -121,16 +128,32 @@ uv add ftllexengine Or: `pip install ftllexengine` -**Works without Babel:** +**Available in parser-only installs:** - FTL syntax parsing (`parse_ftl()`, `serialize_ftl()`) - AST manipulation and transformation - Validation and message introspection +- Zero-dependency runtime helpers such as `CacheConfig`, `FluentNumber`, + `FunctionRegistry`, `fluent_function`, and `make_fluent_number` +- Zero-dependency localization loading types such as `PathResourceLoader`, + `FallbackInfo`, `ResourceLoadResult`, and `LoadSummary` +- Embedded ISO 4217 decimal precision lookup via `get_currency_decimal_digits()` -**Requires Babel:** +**Requires the full runtime install:** - `FluentBundle` (locale-aware formatting) +- `AsyncFluentBundle` - `FluentLocalization` (multi-locale fallback) +- `LocalizationBootConfig` +- Runtime formatter and registry helpers such as `number_format()`, + `datetime_format()`, `currency_format()`, `select_plural_category()`, + `create_default_registry()`, and `get_shared_registry()` - Bidirectional parsing (numbers, dates, currency) -- ISO territory and currency lookups +- Localized ISO territory/currency metadata lookups and ISO code validation helpers + +Public formatting and localization entry points reject unknown locales +instead of silently falling back to `en_US`. +Parser-only facade probes such as `hasattr(ftllexengine.runtime, "number_format")` +and `getattr(ftllexengine, "FluentBundle", None)` treat Babel-backed names +as absent instead of raising during feature detection.
@@ -636,17 +659,17 @@ Alice's invoices format correctly: JPY 28,125,000 in Tokyo, $187,500.00 in New Y ## Architecture at a Glance -| Component | What It Does | Requires Babel? | -|:----------|:-------------|:----------------| -| **Syntax** — `ftllexengine.syntax` | FTL parser, AST, serializer, visitor pattern | No | -| **Runtime** — `ftllexengine.runtime` | `FluentBundle`, message resolution, thread-safe formatting, built-in functions (`NUMBER`, `CURRENCY`, `DATETIME`) | Yes | -| **Localization** — `ftllexengine.localization` | `FluentLocalization` multi-locale fallback chains; `LocalizationBootConfig` strict-mode production boot | Yes | -| **Parsing** — `ftllexengine.parsing` | Bidirectional parsing: numbers, dates, currency back to Python types | Yes | -| **Introspection** — `ftllexengine.introspection` | Message-variable/function extraction, ISO 3166/4217 territory and currency data | Partial | -| **Analysis** — `ftllexengine.analysis` | Dependency-graph helpers such as `detect_cycles()` | No | -| **Validation** — `ftllexengine.validation` | Resource validation, unresolved-reference checks, semantic checks | No | -| **Diagnostics** — `ftllexengine.diagnostics` | Structured error types, error codes, formatting | No | -| **Integrity** — `ftllexengine.integrity` | BLAKE2b checksums, strict mode, immutable exceptions | No | +| Component | What It Does | Install Mode | +|:----------|:-------------|:-------------| +| **Syntax** — `ftllexengine.syntax` | FTL parser, AST, serializer, visitor pattern | Parser-only install | +| **Runtime** — `ftllexengine.runtime` | `FluentBundle`, message resolution, thread-safe formatting, built-in functions (`NUMBER`, `CURRENCY`, `DATETIME`), plus zero-dependency helper types | Mixed: parser-only helpers + full-runtime formatters | +| **Localization** — `ftllexengine.localization` | `FluentLocalization` multi-locale fallback chains; `LocalizationBootConfig` strict-mode production boot; zero-dependency loading types | Mixed: parser-only loading types + full-runtime orchestration | +| **Parsing** — `ftllexengine.parsing` | Bidirectional parsing: numbers, dates, currency back to Python types | Full runtime install | +| **Introspection** — `ftllexengine.introspection` | Message-variable/function extraction, AST reference analysis, and ISO helpers; localized territory/currency metadata needs the full runtime while `get_currency_decimal_digits()` uses embedded tables | Mixed: parser-only helpers + full-runtime localized metadata | +| **Analysis** — `ftllexengine.analysis` | Dependency-graph helpers such as `detect_cycles()` | Parser-only install | +| **Validation** — `ftllexengine.validation` | Resource validation, unresolved-reference checks, semantic checks | Parser-only install | +| **Diagnostics** — `ftllexengine.diagnostics` | Structured error types, error codes, formatting | Parser-only install | +| **Integrity** — `ftllexengine.integrity` | BLAKE2b checksums, strict mode, immutable exceptions | Parser-only install | --- diff --git a/docs/CUSTOM_FUNCTIONS_GUIDE.md b/docs/CUSTOM_FUNCTIONS_GUIDE.md index 4873fdec..66952662 100644 --- a/docs/CUSTOM_FUNCTIONS_GUIDE.md +++ b/docs/CUSTOM_FUNCTIONS_GUIDE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: CUSTOM_FUNCTIONS -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [custom functions, fluent_function, FunctionRegistry, locale injection, add_function] questions: ["how do I add a custom function?", "how does locale injection work?", "should I use a registry or add_function?"] @@ -11,7 +11,7 @@ route: # Custom Functions Guide **Purpose**: Add domain-specific functions to `FluentBundle` or `FluentLocalization`. -**Prerequisites**: Familiarity with `FluentBundle.format_pattern()` and FTL function calls. +**Prerequisites**: Full runtime install (`ftllexengine[babel]`) plus familiarity with `FluentBundle.format_pattern()` and FTL function calls. ## Overview @@ -19,6 +19,7 @@ FTLLexEngine supports two patterns: - `bundle.add_function("NAME", func)` for one bundle or one localization object. - `FunctionRegistry` for reusable or shared function sets. +- This guide assumes the full runtime because the examples attach functions to `FluentBundle` and use `create_default_registry()`. FTL uses uppercase function names by convention. Python callables can keep normal snake_case parameter names; the bridge maps FTL camelCase named arguments onto Python snake_case parameters automatically. diff --git a/docs/DATA_INTEGRITY_ARCHITECTURE.md b/docs/DATA_INTEGRITY_ARCHITECTURE.md index 38ca2b0e..e164cfff 100644 --- a/docs/DATA_INTEGRITY_ARCHITECTURE.md +++ b/docs/DATA_INTEGRITY_ARCHITECTURE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: ARCHITECTURE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [data integrity, strict mode, FrozenFluentError, IntegrityCheckFailedError, cache audit, boot validation] questions: ["how does strict mode relate to integrity?", "what audit evidence does the runtime expose?", "what is boot validation for?"] @@ -11,7 +11,7 @@ route: # Data Integrity Architecture **Purpose**: Summarize the fail-fast and immutable-evidence patterns used by FTLLexEngine. -**Prerequisites**: Familiarity with `FluentBundle`, `FluentLocalization`, and `LocalizationBootConfig`. +**Prerequisites**: Familiarity with `FluentBundle`, `FluentLocalization`, and `LocalizationBootConfig` from the full runtime install. ## Overview @@ -26,7 +26,7 @@ The library pushes validation as early as possible and represents runtime failur - `FluentBundle` and `FluentLocalization` default to `strict=True`. - Resource junk and formatting failures raise instead of silently degrading. -- `strict=False` is an explicit opt-in for fallback-return behavior. +- `strict=False` is an explicit opt-in for `(result, errors)` tuple returns on formatting APIs that would otherwise raise. ## Boot Validation diff --git a/docs/DOC_00_Index.md b/docs/DOC_00_Index.md index 7c63e088..824002b9 100644 --- a/docs/DOC_00_Index.md +++ b/docs/DOC_00_Index.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: INDEX -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [api index, routing, FluentBundle, FluentLocalization, parse_ftl, FunctionRegistry, FrozenFluentError, introspection] questions: ["where is a symbol documented?", "which file documents the runtime APIs?", "which file documents locale parsing and introspection APIs?", "where are syntax, parsing, and diagnostics references?"] @@ -156,6 +156,7 @@ route: | `ValidationResult` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `ValidationResult` | | `ValidationError` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `ValidationError` | | `ValidationWarning` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `ValidationWarning` | +| `ParserAnnotation` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `ParserAnnotation` | | `WarningSeverity` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `WarningSeverity` | | `Diagnostic` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `Diagnostic` | | `DiagnosticCode` | [DOC_05_Diagnostics.md](DOC_05_Diagnostics.md) | `DiagnosticCode` | diff --git a/docs/DOC_01_Core.md b/docs/DOC_01_Core.md index f4afa2fc..4cb56584 100644 --- a/docs/DOC_01_Core.md +++ b/docs/DOC_01_Core.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: CORE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [FluentBundle, AsyncFluentBundle, FluentLocalization, LocalizationBootConfig, PathResourceLoader, LoadSummary, ResourceLoadResult, LocalizationCacheStats, require_clean, get_load_summary] questions: ["how do I format messages?", "how do I load multiple locales?", "how do I inspect localization load results?", "how do I boot localization safely?"] @@ -10,6 +10,10 @@ route: # Core API Reference +Availability note: +- Full runtime only: `FluentBundle`, `AsyncFluentBundle`, `FluentLocalization`, `LocalizationBootConfig`, and `LocalizationCacheStats` +- Parser-only safe: `PathResourceLoader`, `ResourceLoader`, `LoadStatus`, `ResourceLoadResult`, `LoadSummary`, and `FallbackInfo` + --- ## `FluentBundle` @@ -48,10 +52,11 @@ class FluentBundle: ### Constraints - Return: Bundle with normalized locale and empty resource store -- Raises: `ValueError` on invalid locale; `TypeError` on invalid registry +- Raises: `ValueError` on invalid or unknown locale; `TypeError` on invalid registry - State: Mutable resources/functions; optional cache - Thread: Safe - Main methods: `add_resource()`, `add_resource_stream()`, `format_pattern()`, `add_function()`, `validate_resource()` +- Availability: full-runtime only --- @@ -94,6 +99,7 @@ class AsyncFluentBundle: - State: Delegates to an internal bundle instance - Thread: Safe - Async: Formatting and mutation paths run through `asyncio.to_thread()` +- Availability: full-runtime only --- @@ -130,10 +136,11 @@ class FluentLocalization: ### Constraints - Return: Multi-locale runtime with canonicalized locale chain -- Raises: `ValueError` on empty locales or inconsistent loader inputs -- State: Hybrid initialization. When `resource_loader` and `resource_ids` are supplied, resource loads happen eagerly during `__init__()`, bundles for those loaded locales are created eagerly, and untouched fallback bundles stay lazy until first access +- Raises: `ValueError` on empty locales, invalid or unknown locales, or inconsistent loader inputs +- State: Eager resource loading when `resource_loader` and `resource_ids` are supplied; bundles materialize on the first successful load for a locale, while locales with no successful loads stay unmaterialized until a later access path needs them - Thread: Safe - Main methods: `format_value()`, `format_pattern()`, `add_resource()`, `add_function()`, `get_load_summary()`, `require_clean()`, `validate_message_schemas()`, `get_cache_stats()` +- Availability: full-runtime only --- @@ -178,6 +185,7 @@ class LocalizationBootConfig: - State: One-shot boot coordinator - Thread: Safe - Main methods: `boot()`, `boot_simple()`, `from_path()` +- Availability: full-runtime only --- @@ -321,3 +329,4 @@ class LocalizationCacheStats(CacheStats, total=True): - Purpose: Summarize per-locale cache state from `FluentLocalization.get_cache_stats()` - Fields: Includes all `CacheStats` fields aggregated across initialized bundles, plus `bundle_count` - State: Read-only result object +- Availability: full-runtime only diff --git a/docs/DOC_02_SyntaxExpressions.md b/docs/DOC_02_SyntaxExpressions.md index 694bbdb1..9c52e6b5 100644 --- a/docs/DOC_02_SyntaxExpressions.md +++ b/docs/DOC_02_SyntaxExpressions.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: SYNTAX_EXPRESSIONS updated: "2026-04-22" route: diff --git a/docs/DOC_02_SyntaxTypes.md b/docs/DOC_02_SyntaxTypes.md index b56257cc..8e2a28fd 100644 --- a/docs/DOC_02_SyntaxTypes.md +++ b/docs/DOC_02_SyntaxTypes.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: SYNTAX_TYPES updated: "2026-04-22" route: diff --git a/docs/DOC_02_Types.md b/docs/DOC_02_Types.md index a5074d0b..d554c3b5 100644 --- a/docs/DOC_02_Types.md +++ b/docs/DOC_02_Types.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: TYPES updated: "2026-04-22" route: diff --git a/docs/DOC_03_LocaleParsing.md b/docs/DOC_03_LocaleParsing.md index 89506040..04be3565 100644 --- a/docs/DOC_03_LocaleParsing.md +++ b/docs/DOC_03_LocaleParsing.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: LOCALE_PARSING updated: "2026-04-22" route: diff --git a/docs/DOC_03_Parsing.md b/docs/DOC_03_Parsing.md index 9c40259c..0a2da68d 100644 --- a/docs/DOC_03_Parsing.md +++ b/docs/DOC_03_Parsing.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: PARSING updated: "2026-04-22" route: diff --git a/docs/DOC_04_Introspection.md b/docs/DOC_04_Introspection.md index 0e094409..98cb3f9b 100644 --- a/docs/DOC_04_Introspection.md +++ b/docs/DOC_04_Introspection.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: INTROSPECTION updated: "2026-04-22" route: diff --git a/docs/DOC_04_Runtime.md b/docs/DOC_04_Runtime.md index 1b9d344f..fe40c7fe 100644 --- a/docs/DOC_04_Runtime.md +++ b/docs/DOC_04_Runtime.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: RUNTIME -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [CacheConfig, FunctionRegistry, fluent_function, number_format, currency_format, select_plural_category, clear_module_caches] questions: ["how do I configure runtime formatting?", "how do custom functions and registries work?", "where are cache config and write-log entry types documented?"] @@ -10,9 +10,14 @@ route: # Runtime Reference -This reference covers cache configuration, function registries, built-in formatters, plural selection, and cache/audit entry types. +This reference covers cache configuration, function registries, built-in formatters, plural selection, cache/audit entry types, and the root-level `clear_module_caches()` helper. Runtime-adjacent utilities, validators, and package metadata constants are documented in [DOC_04_RuntimeUtilities.md](DOC_04_RuntimeUtilities.md). +Parser-only facade note: +- `CacheConfig`, `FunctionRegistry`, `fluent_function`, `make_fluent_number`, `CacheAuditLogEntry`, `WriteLogEntry`, and `ValidationResult` remain importable in parser-only installs. +- `create_default_registry`, `get_shared_registry`, `number_format`, `datetime_format`, `currency_format`, `select_plural_category`, `FluentBundle`, and `AsyncFluentBundle` require the full runtime install and are absent from `ftllexengine.runtime` in parser-only installs. +- `clear_module_caches()` is a root-level helper that works in both parser-only and full-runtime installs. + ## `CacheConfig` Dataclass that configures optional format-result caching. @@ -93,6 +98,7 @@ def create_default_registry() -> FunctionRegistry: ### Constraints - Return: New mutable registry - State: Fresh object on each call +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -108,6 +114,7 @@ def get_shared_registry() -> FunctionRegistry: ### Constraints - Return: Shared frozen registry - State: Shared singleton-style object +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -134,6 +141,7 @@ def number_format( - Raises: Locale/value boundary errors - State: Pure - Thread: Safe +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -158,6 +166,7 @@ def datetime_format( - Raises: Locale/value boundary errors - State: Pure - Thread: Safe +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -185,6 +194,7 @@ def currency_format( - Raises: Locale/value boundary errors - State: Pure - Thread: Safe +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -207,6 +217,7 @@ def select_plural_category( - Return: CLDR plural category string - State: Pure - Thread: Safe +- Availability: full-runtime only; absent from `ftllexengine.runtime` in parser-only installs --- @@ -241,6 +252,9 @@ def clear_module_caches(components: frozenset[str] | None = None) -> None: | `components` | N | Specific cache components | ### Constraints +- Import: `from ftllexengine import clear_module_caches` +- Raises: `ValueError` on unknown cache selectors +- Selectors: `"parsing.currency"`, `"parsing.dates"`, `"locale"`, `"runtime.locale_context"`, `"introspection.message"`, `"introspection.iso"` - State: Mutates module cache state - Thread: Safe @@ -248,30 +262,23 @@ def clear_module_caches(components: frozenset[str] | None = None) -> None: ## `CacheAuditLogEntry` -Dataclass representing one immutable cache audit-log record. +Public alias for the cache audit-log record type. ### Signature ```python -@dataclass(frozen=True, slots=True) -class CacheAuditLogEntry: - operation: str - key_hash: str - timestamp: float - sequence: int - checksum_hex: str - wall_time_unix: float +CacheAuditLogEntry = WriteLogEntry ``` ### Constraints -- Purpose: Public audit-log payload -- State: Immutable -- Thread: Safe +- Purpose: Stable public alias returned by bundle/localization cache-audit APIs +- Underlying type: `WriteLogEntry` +- Import: `from ftllexengine.runtime import CacheAuditLogEntry` or `from ftllexengine.localization import CacheAuditLogEntry` --- ## `WriteLogEntry` -Dataclass alias used by the runtime cache implementation for audit-log records. +Immutable dataclass that represents one cache audit-log record. ### Signature ```python @@ -286,7 +293,7 @@ class WriteLogEntry: ``` ### Constraints -- Purpose: Same public payload shape as `CacheAuditLogEntry` +- Purpose: Underlying runtime cache dataclass behind the `CacheAuditLogEntry` public alias - State: Immutable - Thread: Safe diff --git a/docs/DOC_04_RuntimeUtilities.md b/docs/DOC_04_RuntimeUtilities.md index 6b7dff2b..402fcbeb 100644 --- a/docs/DOC_04_RuntimeUtilities.md +++ b/docs/DOC_04_RuntimeUtilities.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: RUNTIME_UTILITIES -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [detect_cycles, normalize_locale, get_system_locale, require_locale_code, __version__, require_date, require_datetime] questions: ["where are runtime utility exports documented?", "what package metadata constants are public?", "which boundary validators and locale helpers are exported from the root package?"] @@ -19,7 +19,7 @@ Function that detects cycles in a dependency graph. ### Signature ```python -def detect_cycles(dependencies: Mapping[str, set[str]]) -> list[list[str]]: +def detect_cycles(dependencies: dict[str, set[str]]) -> list[list[str]]: ``` ### Parameters @@ -68,6 +68,7 @@ def get_system_locale(*, raise_on_failure: bool = False) -> str: ### Constraints - Return: normalized POSIX-style locale string - Fallback: returns `"en_us"` when detection fails and `raise_on_failure` is false +- Raises: `RuntimeError` when `raise_on_failure` is true and detection fails - State: Pure with respect to library state; reads OS process locale and env vars --- diff --git a/docs/DOC_05_Diagnostics.md b/docs/DOC_05_Diagnostics.md index 7199d229..e7ed272c 100644 --- a/docs/DOC_05_Diagnostics.md +++ b/docs/DOC_05_Diagnostics.md @@ -1,10 +1,10 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: DIAGNOSTICS -updated: "2026-04-22" +updated: "2026-04-23" route: - keywords: [ValidationResult, ValidationError, ValidationWarning, DiagnosticCode, DiagnosticFormatter, OutputFormat, SourceSpan] + keywords: [ParserAnnotation, ValidationResult, ValidationError, ValidationWarning, DiagnosticCode, DiagnosticFormatter, OutputFormat, SourceSpan] questions: ["what validation result types exist?", "how do I format diagnostics output?", "where are diagnostic codes and source spans documented?"] --- @@ -78,6 +78,26 @@ class ValidationWarning: --- +## `ParserAnnotation` + +Structural protocol for parser annotations stored inside `ValidationResult.annotations`. + +### Signature +```python +class ParserAnnotation(Protocol): + code: str + message: str + arguments: tuple[tuple[str, str], ...] | None + span: object | None +``` + +### Constraints +- Import: `from ftllexengine.diagnostics import ParserAnnotation` +- Purpose: allows `ValidationResult` to store parser-produced annotations by structural contract, not one concrete implementation class +- Typical producer: `ftllexengine.syntax.ast.Annotation` + +--- + ## `ValidationResult` Unified immutable validation result. @@ -88,7 +108,7 @@ Unified immutable validation result. class ValidationResult: errors: tuple[ValidationError, ...] warnings: tuple[ValidationWarning, ...] - annotations: tuple[Annotation, ...] + annotations: tuple[ParserAnnotation, ...] ``` ### Constraints @@ -96,6 +116,7 @@ class ValidationResult: - Produced by: `validate_resource()` - Properties: `is_valid`, `error_count`, `warning_count`, `annotation_count` - Factories: `valid()`, `invalid()`, `from_annotations()` +- Annotation contract: stores any object satisfying `ParserAnnotation`; parser AST `Annotation` nodes are the common implementation - Formatting helper: `.format()` delegates to `DiagnosticFormatter` --- diff --git a/docs/DOC_05_Errors.md b/docs/DOC_05_Errors.md index ed61bd4f..646463e5 100644 --- a/docs/DOC_05_Errors.md +++ b/docs/DOC_05_Errors.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: ERRORS updated: "2026-04-22" route: @@ -116,6 +116,8 @@ class BabelImportError(ImportError): ### Constraints - Import: `from ftllexengine.introspection import BabelImportError` - Purpose: consistent optional-dependency failure for CLDR-backed features +- Trigger: only for genuinely missing Babel in parser-only installs +- Broken-install path: internal Babel import failures bubble their original `ImportError` - Message: instructs callers to install `ftllexengine[babel]` --- diff --git a/docs/DOC_06_Testing.md b/docs/DOC_06_Testing.md index 9d2417ed..c95d895f 100644 --- a/docs/DOC_06_Testing.md +++ b/docs/DOC_06_Testing.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: TESTING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [testing, lint, pytest, fuzz, HypoFuzz, Atheris, test.sh, lint.sh, check.sh] questions: ["how do I run lint and tests?", "what is the fuzz marker for?", "which scripts drive testing?"] @@ -56,9 +56,10 @@ uv run python scripts/run_examples.py [--pattern '*.py'] [--list] ``` ### Constraints -- Purpose: keep `examples/*.py` runnable as a supported, repeatable gate +- Purpose: keep `examples/*.py` runnable and semantically self-checking as a supported, repeatable gate - Import mode: clears `PYTHONPATH` so examples run against the installed package contract -- Failure mode: exits non-zero when any example script fails +- Output contracts: every shipped example must register a stdout contract so semantic regressions cannot hide behind exit code `0` +- Failure mode: exits non-zero when any example script fails, omits expected contract markers, or is missing a registered contract --- @@ -159,3 +160,4 @@ Repository script for native Atheris/libFuzzer targets. - Purpose: Run, replay, list, and minimize Atheris findings - Behavior: Manages `.venv-atheris` separately from the main project venvs - Output: Target-oriented CLI workflow around the `fuzz_atheris/` tree +- `--list`: shows stored crashes and finding artifacts; use [fuzz_atheris/README.md](../fuzz_atheris/README.md) for the target inventory diff --git a/docs/FUZZING_GUIDE.md b/docs/FUZZING_GUIDE.md index 6b25bbe5..42e02d5d 100644 --- a/docs/FUZZING_GUIDE.md +++ b/docs/FUZZING_GUIDE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: FUZZING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [fuzzing, HypoFuzz, Atheris, Hypothesis, fuzz_hypofuzz.sh, fuzz_atheris.sh] questions: ["which fuzzer should I use?", "how do I start fuzzing?", "how do I reproduce a fuzz failure?"] @@ -25,13 +25,14 @@ Use: ```bash ./scripts/fuzz_hypofuzz.sh ./scripts/fuzz_hypofuzz.sh --deep --time 300 -./scripts/fuzz_atheris.sh --list +./scripts/fuzz_atheris.sh numbers --time 60 ``` ## Choosing A Surface - Prefer HypoFuzz when you are exploring Python-level invariants and stateful/property-based tests. - Prefer Atheris when you need native-style mutation, corpus management, or target-specific replay/minimization. +- `./scripts/fuzz_atheris.sh --list` inspects stored crashes and finding artifacts; it does not enumerate target names. ## Related Guides diff --git a/docs/FUZZING_GUIDE_ATHERIS.md b/docs/FUZZING_GUIDE_ATHERIS.md index 559392a5..8bf7b1b6 100644 --- a/docs/FUZZING_GUIDE_ATHERIS.md +++ b/docs/FUZZING_GUIDE_ATHERIS.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: FUZZING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [atheris, libfuzzer, fuzz_atheris.sh, replay, minimize, corpus] questions: ["how do I run an Atheris target?", "how do I replay a finding?", "how does the Atheris environment get created?"] @@ -18,7 +18,7 @@ route: ```bash ./scripts/fuzz_atheris.sh --help ./scripts/fuzz_atheris.sh numbers --time 60 -./scripts/fuzz_atheris.sh --list +./scripts/fuzz_atheris.sh --list # stored crashes/findings, not target names ./scripts/fuzz_atheris.sh --replay runtime path/to/finding ``` @@ -29,6 +29,7 @@ The script manages `.venv-atheris` itself and keeps it separate from the normal ## Useful Operations - `--list` to inspect captured findings. +- Target names live in [../fuzz_atheris/README.md](../fuzz_atheris/README.md). - `--replay` to replay stored findings without starting a fresh fuzz run. - `--minimize TARGET FILE` to shrink a failing input for one target. - `--corpus` to run the corpus health check. diff --git a/docs/FUZZING_GUIDE_HYPOFUZZ.md b/docs/FUZZING_GUIDE_HYPOFUZZ.md index ab4bf46d..75821607 100644 --- a/docs/FUZZING_GUIDE_HYPOFUZZ.md +++ b/docs/FUZZING_GUIDE_HYPOFUZZ.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: FUZZING updated: "2026-04-22" route: diff --git a/docs/LOCALE_GUIDE.md b/docs/LOCALE_GUIDE.md index 3741e2ef..a1c32520 100644 --- a/docs/LOCALE_GUIDE.md +++ b/docs/LOCALE_GUIDE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: LOCALE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [locale, NUMBER, DATETIME, CURRENCY, normalize_locale, get_system_locale, use_isolating] questions: ["why did my number not format?", "what locale string should I use?", "what does use_isolating do?"] @@ -11,7 +11,7 @@ route: # Locale Guide **Purpose**: Explain how locale normalization and locale-aware formatting work in FTLLexEngine. -**Prerequisites**: Basic Fluent syntax. +**Prerequisites**: Basic Fluent syntax. The formatting examples use the full runtime install; `normalize_locale()` and `get_system_locale()` also work in parser-only installs. ## Overview @@ -35,18 +35,23 @@ assert fmt == "1.234,50\u00a0€" ## Locale Codes - Public runtime APIs normalize locale codes to the canonical internal form. -- `normalize_locale()` is useful when you need the exact canonical string yourself. -- `get_system_locale()` reads the OS and environment variables for a default locale. +- `normalize_locale()` is useful when you need the exact canonical string yourself, but it only canonicalizes spelling and separators. +- Public formatting and localization entry points validate against Babel/CLDR and raise `ValueError` for unknown locales. +- `get_system_locale()` reads the OS and environment variables for a default locale and falls back to `"en_us"` unless `raise_on_failure=True` is used. ```python -from ftllexengine import get_system_locale, normalize_locale +from ftllexengine import FluentBundle, get_system_locale, normalize_locale assert normalize_locale("de-DE") == "de_de" try: - detected = get_system_locale() + FluentBundle("xx_INVALID") except ValueError: - detected = None -assert detected is None or isinstance(detected, str) + pass +else: + raise AssertionError("Unknown locales must raise ValueError") +detected = get_system_locale() +assert isinstance(detected, str) +assert detected ``` ## Bidi Isolation diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0c77e329..df2f3fc8 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: MIGRATION -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [migration, fluent.runtime, FluentBundle, FluentLocalization, strict mode] questions: ["how do I migrate from fluent.runtime?", "what changes when I switch to FTLLexEngine?"] @@ -11,7 +11,7 @@ route: # Migration From `fluent.runtime` **Purpose**: Highlight the main API and behavior differences when moving to FTLLexEngine. -**Prerequisites**: Familiarity with `fluent.runtime`. +**Prerequisites**: Familiarity with `fluent.runtime` and the FTLLexEngine full runtime install (`ftllexengine[babel]`). ## High-Level Differences diff --git a/docs/PARSING_GUIDE.md b/docs/PARSING_GUIDE.md index e29a420f..ee560114 100644 --- a/docs/PARSING_GUIDE.md +++ b/docs/PARSING_GUIDE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: PARSING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [parsing, parse_decimal, parse_currency, parse_date, parse_datetime, parse_fluent_number] questions: ["how do I parse localized user input?", "how do I do roundtrip formatting and parsing?", "what do parse errors look like?"] @@ -11,7 +11,7 @@ route: # Parsing Guide **Purpose**: Parse locale-formatted numbers, currency, dates, and datetimes back into Python values. -**Prerequisites**: Babel-enabled install (`ftllexengine[babel]`). +**Prerequisites**: Full runtime install (`ftllexengine[babel]`). ## Overview diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md index 7d8bcb5f..fca8de48 100644 --- a/docs/QUICK_REFERENCE.md +++ b/docs/QUICK_REFERENCE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: REFERENCE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [quick reference, cheat sheet, fluentbundle, fluentlocalization, parsing, validation, boot] questions: ["show me the common commands", "what is the smallest working example?", "how do I boot localization safely?"] @@ -13,10 +13,10 @@ route: ## Install ```bash +# Full runtime (locale formatting, localization, parsing) uv add ftllexengine[babel] -``` -```bash +# Parser-only (syntax, AST, validation, introspection) uv add ftllexengine ``` @@ -37,7 +37,7 @@ assert result == "Hello, Alice!" ```python from ftllexengine import FluentLocalization -l10n = FluentLocalization(["lv_LV", "en_US"], strict=False) +l10n = FluentLocalization(["lv_LV", "en_US"]) l10n.add_resource("en_US", "checkout = Checkout") l10n.add_resource("lv_LV", "checkout = Apmaksa") result, errors = l10n.format_value("checkout") @@ -117,4 +117,5 @@ from ftllexengine import clear_module_caches clear_module_caches() clear_module_caches(frozenset({"parsing.dates", "locale"})) +# Unknown selector names raise ValueError instead of being ignored. ``` diff --git a/docs/RELEASE_PROTOCOL.md b/docs/RELEASE_PROTOCOL.md index 0ac4bd13..2b863a8f 100644 --- a/docs/RELEASE_PROTOCOL.md +++ b/docs/RELEASE_PROTOCOL.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: RELEASE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [release, gh, github release, pypi, tag, assets, publish, verify, worktree, main] questions: ["how do I cut a release?", "how do I publish GitHub assets?", "how do I verify a release handoff?", "how do I rerun publish for an existing tag?"] @@ -11,7 +11,7 @@ route: # Release Protocol **Purpose**: Publish a tagged FTLLexEngine release through GitHub CLI and verify the GitHub Release and PyPI handoff. -**Prerequisites**: `gh` installed and authenticated, release version already set in `pyproject.toml`, and a checkout topology that can produce a clean release payload. +**Prerequisites**: `gh` installed and authenticated, the target release version chosen, and a checkout topology that can produce a clean release payload. ## Overview @@ -72,10 +72,13 @@ PRIMARY_CHECKOUT="$(git rev-parse --show-toplevel)" git fetch origin --prune git fetch origin --tags RELEASE_WORKTREE="$(mktemp -d -t ftllexengine-release-XXXXXX)" -git worktree add -b release/X.Y.Z "$RELEASE_WORKTREE" origin/main +git worktree add --detach "$RELEASE_WORKTREE" origin/main cd "$RELEASE_WORKTREE" ``` +This flow intentionally keeps the worktree detached during pre-flight. Create +`release/X.Y.Z` only after Step 2 passes. + If the unpublished release payload exists only in the dirty primary checkout, move it explicitly before running release gates in the clean worktree. Preferred: create a local bootstrap branch that captures the payload, then add the release worktree from that branch. Acceptable: export one @@ -88,10 +91,15 @@ git switch -c codex/release-bootstrap-X.Y.Z git add -A git commit -m "release: bootstrap X.Y.Z payload" RELEASE_WORKTREE="$(mktemp -d -t ftllexengine-release-XXXXXX)" -git worktree add -b release/X.Y.Z "$RELEASE_WORKTREE" codex/release-bootstrap-X.Y.Z +git worktree add --detach "$RELEASE_WORKTREE" codex/release-bootstrap-X.Y.Z cd "$RELEASE_WORKTREE" ``` +If the bootstrap payload intentionally left the final release version or changelog entry unresolved, +finish those edits inside the clean release worktree before Step 2. Treat the clean worktree as the +authoritative place to finalize `pyproject.toml`, versioned markdown frontmatter, lockfiles, and the +target `CHANGELOG.md` release entry. + ## Step 2: Pre-flight And Release Readiness Run the local gates first: @@ -112,16 +120,19 @@ Also confirm: - `CHANGELOG.md` contains the target release entry. - `pyproject.toml` has the final target version. +- all version-carrying metadata that ships with the repo (for example markdown frontmatter and + `uv.lock`) is synchronized to that target version. - the release checkout is based on current `origin/main` or you explicitly understand the delta. Do not cut the release branch or tag anything while any gate is red. ## Step 3: Release Branch And Staging Checkpoint -Create the release branch and treat staging as a scope-verification checkpoint: +Create the release branch and treat staging as a scope-verification checkpoint for the delta from +the branch point: ```bash -git checkout -b release/X.Y.Z +git switch -c release/X.Y.Z git add git status --short git diff --cached --name-status @@ -135,6 +146,9 @@ Requirements before continuing: - `git status --short` shows no intended release file left unstaged or untracked. - `git diff --cached --name-status` matches the expected file set. - `git diff --cached --stat` confirms the staged payload is the release you intend to ship. +- If the branch started from a bootstrap payload rather than `origin/main`, interpret this + checkpoint narrowly: it proves only the release-finalization delta since the bootstrap commit, + not the full release scope against `origin/main`. If the staged diff is incomplete or polluted, fix the branch before committing. @@ -160,7 +174,9 @@ gh pr checks Rules: -- `gh pr diff --name-only` must still match the intended release file set. +- `gh pr diff --name-only` must match the full intended release file set against `origin/main`. +- For bootstrap-path releases, treat this PR diff checkpoint as the authoritative full-scope review; + it supersedes the narrower Step 3 staged-diff view. - If `gh pr diff --name-only` fails with HTTP 406 because the PR diff is too large, fall back to GitHub's paginated file list API and the local branch comparison: diff --git a/docs/TERMINOLOGY.md b/docs/TERMINOLOGY.md index 6bea954e..3342a4fa 100644 --- a/docs/TERMINOLOGY.md +++ b/docs/TERMINOLOGY.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: TERMINOLOGY -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [terminology, glossary, message, term, resource, locale code, strict mode] questions: ["what does resource mean here?", "what is the difference between a message and a term?", "what does strict mode mean in FTLLexEngine?"] @@ -25,6 +25,8 @@ route: | Locale code | Canonical locale identifier used by the runtime | | Strict mode | Fail-fast behavior that raises integrity exceptions instead of returning soft fallbacks | | Boot validation | Startup path that proves resource cleanliness and schema correctness before traffic | +| Parser-only install | `pip install ftllexengine`; syntax, AST, validation, and zero-dependency helper surfaces without Babel | +| Full runtime install | `pip install ftllexengine[babel]`; bundle/localization formatting, locale parsing, and Babel-backed helper surfaces | ## Resource Disambiguation @@ -38,4 +40,5 @@ route: - Use `Fluent` when referring to the Fluent specification or runtime concepts. - Use `FTL` when referring to the language syntax or `.ftl` files. +- Prefer `parser-only install` and `full runtime install` over ad-hoc phrases like “without Babel” or “with Babel” when describing supported install modes. - Use readable input examples such as `en_US`, `de_DE`, and `lv_LV`; reserve lowercase forms like `en_us` for normalized internal/cache-key examples. diff --git a/docs/THREAD_SAFETY.md b/docs/THREAD_SAFETY.md index f360662e..536063ce 100644 --- a/docs/THREAD_SAFETY.md +++ b/docs/THREAD_SAFETY.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: ARCHITECTURE -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [thread safety, concurrency, FluentBundle, FluentLocalization, AsyncFluentBundle, shared bundle] questions: ["is FluentBundle thread-safe?", "can I share a localization object across threads?", "what does AsyncFluentBundle do?"] @@ -11,7 +11,7 @@ route: # Thread Safety **Purpose**: Describe the concurrency guarantees of the public runtime classes. -**Prerequisites**: None. +**Prerequisites**: Full runtime install (`ftllexengine[babel]`). ## Overview diff --git a/docs/TYPE_HINTS_GUIDE.md b/docs/TYPE_HINTS_GUIDE.md index 8d32d035..1dee6daa 100644 --- a/docs/TYPE_HINTS_GUIDE.md +++ b/docs/TYPE_HINTS_GUIDE.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: TYPE_HINTS updated: "2026-04-22" route: diff --git a/docs/VALIDATION_GUIDE.md b/docs/VALIDATION_GUIDE.md index 9d139238..f0549226 100644 --- a/docs/VALIDATION_GUIDE.md +++ b/docs/VALIDATION_GUIDE.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: VALIDATION -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [validation, validate_resource, ValidationResult, require_clean, boot validation, message schemas] questions: ["how do I validate FTL before loading it?", "how do I fail fast at startup?", "how do I validate message variables?"] @@ -11,7 +11,7 @@ route: # Validation Guide **Purpose**: Validate FTL source, loaded resources, and message-variable contracts before serving traffic. -**Prerequisites**: Basic familiarity with `FluentBundle` or `FluentLocalization`. +**Prerequisites**: `validate_resource()` works in parser-only installs; the loaded-resource and boot-validation sections assume the full runtime install. ## Resource Validation @@ -34,7 +34,7 @@ assert result.warning_count == 0 ## Loaded-Resource Validation -`FluentLocalization.require_clean()` converts load summary problems into an `IntegrityCheckFailedError`. This is the fail-fast path for production startup. +`FluentLocalization.require_clean()` converts load summary problems into an `IntegrityCheckFailedError`. This is the fail-fast path for production startup and therefore requires the full runtime install. ## Message Variable Contracts diff --git a/examples/README.md b/examples/README.md index 5136b70c..e05690c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: EXAMPLES -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [examples, quickstart, parser-only, localization, custom functions, thread safety, benchmarks] questions: ["what examples are available?", "how do I run the examples?", "which example should I start with?"] @@ -34,7 +34,7 @@ uv run --python 3.13 python scripts/run_examples.py | Script | Focus | |:-------|:------| | `quickstart.py` | Single-locale bundle usage, variables, plurals, parsing handoff | -| `parser_only.py` | Parser-only install surface: parse, validate, inspect, serialize | +| `parser_only.py` | Parser-only install surface: zero-dependency helper facades, parse, validate, inspect, serialize | | `locale_fallback.py` | `FluentLocalization`, fallback chains, disk and custom loaders | | `bidirectional_formatting.py` | Locale-aware parsing for numbers, dates, currency | | `custom_functions.py` | `FunctionRegistry`, `bundle.add_function()`, `@fluent_function` | @@ -48,7 +48,7 @@ uv run --python 3.13 python scripts/run_examples.py ## Picking A Starting Point - New to the runtime: start with `examples/quickstart.py`. -- Working without Babel: start with `examples/parser_only.py`. +- Working in a parser-only install: start with `examples/parser_only.py`. - Building a multi-locale app: use `examples/locale_fallback.py`. - Accepting localized user input: use `examples/bidirectional_formatting.py`. diff --git a/examples/README_TYPE_CHECKING.md b/examples/README_TYPE_CHECKING.md index e15f5102..389d3de8 100644 --- a/examples/README_TYPE_CHECKING.md +++ b/examples/README_TYPE_CHECKING.md @@ -1,6 +1,6 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: EXAMPLES updated: "2026-04-22" route: diff --git a/examples/ftl_linter.py b/examples/ftl_linter.py index 4f1facfd..fb25b32f 100644 --- a/examples/ftl_linter.py +++ b/examples/ftl_linter.py @@ -1,12 +1,12 @@ """FTL Linter Example - Demonstrating AST Parser Tooling API. -PARSER-ONLY: This example works WITHOUT Babel. Install with: - pip install ftllexengine (no [babel] extra needed) +PARSER-ONLY INSTALL: This example is designed for: + pip install ftllexengine This example shows how to use FTLLexEngine's AST parser API to build a simple FTL linter that detects common issues: -- Messages without values +- Messages whose value is empty or attribute-only - Duplicate message IDs - Unknown function calls - Undefined message/term references @@ -26,13 +26,17 @@ from dataclasses import dataclass from pathlib import Path +from typing import TypeIs from ftllexengine import parse_ftl from ftllexengine.syntax.ast import ( + Attribute, FunctionReference, Message, MessageReference, + Pattern, Resource, + Term, TermReference, VariableReference, ) @@ -57,7 +61,13 @@ def __init__(self) -> None: super().__init__() self.issues: list[LintIssue] = [] self.message_ids: set[str] = set() - self.current_message_id: str | None = None + self.term_ids: set[str] = set() + self.current_location: str | None = None + + @staticmethod + def _has_explicit_pattern_value(node_value: Pattern | None) -> TypeIs[Pattern]: + """Return True when a parsed Pattern contains actual value elements.""" + return isinstance(node_value, Pattern) and bool(node_value.elements) def visit_Resource(self, node: Resource) -> None: # pylint: disable=invalid-name """Visit resource and check for duplicate message IDs. @@ -78,6 +88,8 @@ def visit_Resource(self, node: Resource) -> None: # pylint: disable=invalid-nam ) ) self.message_ids.add(id_node.name) + case Term(id=id_node): + self.term_ids.add(id_node.name) # Second pass: visit each message for entry in node.entries: @@ -88,10 +100,12 @@ def visit_Message(self, node: Message) -> None: # pylint: disable=invalid-name Visitor pattern: visit_* methods follow stdlib ast.NodeVisitor convention. """ - self.current_message_id = node.id.name + previous_location = self.current_location + self.current_location = node.id.name + pattern = node.value # Check if message has a value - if not node.value: + if not self._has_explicit_pattern_value(pattern): self.issues.append( LintIssue( severity="warning", @@ -102,14 +116,40 @@ def visit_Message(self, node: Message) -> None: # pylint: disable=invalid-name ) # Visit pattern to check references and functions - if node.value: - self.visit(node.value) + if self._has_explicit_pattern_value(pattern): + self.visit(pattern) # Visit attributes for attr in node.attributes: self.visit(attr) - self.current_message_id = None + self.current_location = previous_location + + def visit_Term(self, node: Term) -> None: # pylint: disable=invalid-name + """Visit term and check references in its value and attributes.""" + previous_location = self.current_location + self.current_location = f"-{node.id.name}" + pattern = node.value + + if self._has_explicit_pattern_value(pattern): + self.visit(pattern) + + for attr in node.attributes: + self.visit(attr) + + self.current_location = previous_location + + def visit_Attribute(self, node: Attribute) -> None: # pylint: disable=invalid-name + """Track attribute-specific locations for nested lint issues.""" + previous_location = self.current_location + base_location = previous_location or "unknown" + self.current_location = f"{base_location}.{node.id.name}" + pattern = node.value + + if self._has_explicit_pattern_value(pattern): + self.visit(pattern) + + self.current_location = previous_location def visit_VariableReference(self, node: VariableReference) -> None: # pylint: disable=invalid-name """Visit variable reference (no validation needed - runtime provided). @@ -138,7 +178,7 @@ def visit_FunctionReference(self, node: FunctionReference) -> None: # pylint: d severity="warning", rule="unknown-function", message=f"Unknown function: {node.id.name}", - location=self.current_message_id or "unknown", + location=self.current_location or "unknown", ) ) @@ -155,21 +195,52 @@ def visit_MessageReference(self, node: MessageReference) -> None: # pylint: dis severity="error", rule="undefined-reference", message=f"Reference to undefined message: {node.id.name}", - location=self.current_message_id or "unknown", + location=self.current_location or "unknown", ) ) self.generic_visit(node) def visit_TermReference(self, node: TermReference) -> None: # pylint: disable=invalid-name - """Check term references (not implemented in this example). + """Check term references. Visitor pattern: visit_* methods follow stdlib ast.NodeVisitor convention. """ - # In a real linter, you'd track terms too + if node.id.name not in self.term_ids: + self.issues.append( + LintIssue( + severity="error", + rule="undefined-term", + message=f"Reference to undefined term: -{node.id.name}", + location=self.current_location or "unknown", + ) + ) self.generic_visit(node) +def _issue_rules(issues: list[LintIssue]) -> set[str]: + """Return the unique rule identifiers present in a lint result.""" + return {issue.rule for issue in issues} + + +def _require_issue_rules( + *, + label: str, + issues: list[LintIssue], + expected_rules: set[str], +) -> None: + """Assert that the lint result includes exactly the expected rule IDs.""" + actual_rules = _issue_rules(issues) + if actual_rules != expected_rules: + msg = ( + f"{label} expected lint rules {sorted(expected_rules)!r}, " + f"got {sorted(actual_rules)!r}" + ) + raise AssertionError(msg) + + print(f"[PASS] {label}") + + def lint_ftl_file(source: str) -> list[LintIssue]: # pylint: disable=redefined-outer-name """Lint FTL source code. @@ -227,6 +298,11 @@ def print_lint_results(lint_issues: list[LintIssue]) -> None: # pylint: disable issues = lint_ftl_file(clean_ftl) print_lint_results(issues) + _require_issue_rules( + label="Clean FTL stays clean", + issues=issues, + expected_rules=set(), + ) # Example 2: Duplicate message IDs print("\n" + "=" * 60) @@ -240,6 +316,11 @@ def print_lint_results(lint_issues: list[LintIssue]) -> None: # pylint: disable issues = lint_ftl_file(duplicate_ids_ftl) print_lint_results(issues) + _require_issue_rules( + label="Duplicate message IDs detected", + issues=issues, + expected_rules={"duplicate-id"}, + ) # Example 3: Unknown function print("\n" + "=" * 60) @@ -252,6 +333,11 @@ def print_lint_results(lint_issues: list[LintIssue]) -> None: # pylint: disable issues = lint_ftl_file(unknown_function_ftl) print_lint_results(issues) + _require_issue_rules( + label="Unknown functions detected", + issues=issues, + expected_rules={"unknown-function"}, + ) # Example 4: Undefined message reference print("\n" + "=" * 60) @@ -264,6 +350,11 @@ def print_lint_results(lint_issues: list[LintIssue]) -> None: # pylint: disable issues = lint_ftl_file(undefined_ref_ftl) print_lint_results(issues) + _require_issue_rules( + label="Undefined message references detected", + issues=issues, + expected_rules={"undefined-reference"}, + ) # Example 5: Message without value print("\n" + "=" * 60) @@ -278,10 +369,32 @@ def print_lint_results(lint_issues: list[LintIssue]) -> None: # pylint: disable issues = lint_ftl_file(no_value_ftl) print_lint_results(issues) + _require_issue_rules( + label="Attribute-only messages flagged", + issues=issues, + expected_rules={"no-value"}, + ) + + # Example 6: Undefined term reference + print("\n" + "=" * 60) + print("Example 6: Undefined Term Reference") + print("=" * 60) + + undefined_term_ftl = """ +price = Total: { -missing-currency } +""" + + issues = lint_ftl_file(undefined_term_ftl) + print_lint_results(issues) + _require_issue_rules( + label="Undefined term references detected", + issues=issues, + expected_rules={"undefined-term"}, + ) - # Example 6: Lint a real file + # Example 7: Lint a real file print("\n" + "=" * 60) - print("Example 6: Lint Real File (if exists)") + print("Example 7: Lint Real File (if exists)") print("=" * 60) ftl_file = Path("locales/en/messages.ftl") diff --git a/examples/ftl_transform.py b/examples/ftl_transform.py index b75896c3..4741c31a 100644 --- a/examples/ftl_transform.py +++ b/examples/ftl_transform.py @@ -1,7 +1,7 @@ """FTL Transformer Example - Demonstrating AST Modification API. -PARSER-ONLY: This example works WITHOUT Babel. Install with: - pip install ftllexengine (no [babel] extra needed) +PARSER-ONLY INSTALL: This example is designed for: + pip install ftllexengine This example shows how to use FTLLexEngine's ASTTransformer to modify FTL files programmatically: diff --git a/examples/parser_only.py b/examples/parser_only.py index 2b304428..4709610c 100644 --- a/examples/parser_only.py +++ b/examples/parser_only.py @@ -1,10 +1,11 @@ -"""Parser-Only Example - FTL Parsing Without Babel. +"""Parser-Only Example - FTL Parsing In A Parser-Only Install. -PARSER-ONLY: This example works WITHOUT Babel. Install with: - pip install ftllexengine (no [babel] extra needed) +PARSER-ONLY INSTALL: This example is designed for: + pip install ftllexengine Demonstrates everything you can do with FTLLexEngine's parser-only mode: +0. Access zero-dependency runtime/localization helper facades 1. Parse FTL source to AST 2. Inspect message structure 3. Extract variables and function references @@ -24,6 +25,28 @@ from __future__ import annotations +def example_0_zero_dependency_facades() -> None: + """Show helper facades that stay importable in parser-only installs.""" + from decimal import Decimal + + from ftllexengine import CacheConfig, FluentNumber, LoadSummary, PathResourceLoader + + print("=" * 60) + print("Example 0: Zero-Dependency Helper Facades") + print("=" * 60) + + cache = CacheConfig(size=10) + manual = FluentNumber(value=Decimal("12.50"), formatted="12.50", precision=2) + loader = PathResourceLoader("locales/{locale}") + summary = LoadSummary(results=()) + + print(f"CacheConfig.size: {cache.size}") + print(f"FluentNumber: {manual!s}") + print(f"PathResourceLoader template: {loader.base_path}") + print(f"Empty LoadSummary all_clean: {summary.all_clean}") + print() + + def example_1_basic_parsing() -> None: """Parse FTL source and inspect the AST.""" from ftllexengine import parse_ftl @@ -97,10 +120,7 @@ def example_2_variable_extraction() -> None: def example_3_validation() -> None: """Validate FTL source for errors and warnings.""" - # mypy: Re-export from ftllexengine.validation not recognized via top-level __init__.py. - # Runtime import works correctly; explicit `as validate_resource` re-export pattern applied - # but mypy still misses it. Use direct submodule import for type safety. - from ftllexengine.validation import validate_resource + from ftllexengine import validate_resource print("=" * 60) print("Example 3: Validation") @@ -114,19 +134,36 @@ def example_3_validation() -> None: """ result = validate_resource(valid_ftl) + assert result.is_valid + assert result.error_count == 0 + assert result.warning_count == 0 print(f"Valid FTL: is_valid={result.is_valid}, errors={result.error_count}") - # Invalid FTL with issues - invalid_ftl = """ + # Warning-only FTL: semantic issues do not change is_valid + warning_ftl = """ greeting = Hello, { $name }! greeting = Duplicate ID! missing-ref = Uses { -undefined-term } """ - result = validate_resource(invalid_ftl) - print(f"Invalid FTL: is_valid={result.is_valid}, warnings={result.warning_count}") + result = validate_resource(warning_ftl) + assert result.is_valid + assert result.warning_count == 2 + print(f"Warning-only FTL: is_valid={result.is_valid}, warnings={result.warning_count}") for warning in result.warnings: print(f" - {warning.code.name}: {warning.message}") + print("[PASS] Warning-only validation semantics verified") + + # Invalid syntax FTL: parser annotations make the result invalid + invalid_syntax_ftl = """ +greeting = Hello, { $name ! +""" + + result = validate_resource(invalid_syntax_ftl) + assert not result.is_valid + assert result.error_count > 0 or result.annotation_count > 0 + print(f"Invalid syntax FTL: is_valid={result.is_valid}, errors={result.error_count}") + print("[PASS] Invalid syntax semantics verified") print() @@ -262,9 +299,10 @@ def main() -> None: """Run all parser-only examples.""" print() print("FTLLexEngine Parser-Only Examples") - print("No Babel required - pure Python parsing!") + print("Parser-only install surface - pure Python parsing!") print() + example_0_zero_dependency_facades() example_1_basic_parsing() example_2_variable_extraction() example_3_validation() diff --git a/fuzz_atheris/README.md b/fuzz_atheris/README.md index b4b8fe5e..35feb829 100644 --- a/fuzz_atheris/README.md +++ b/fuzz_atheris/README.md @@ -1,8 +1,8 @@ --- afad: "3.5" -version: "0.163.0" +version: "0.164.0" domain: FUZZING -updated: "2026-04-22" +updated: "2026-04-23" route: keywords: [atheris, fuzz inventory, fuzz targets, libfuzzer, corpus] questions: ["what do the Atheris fuzzers cover?", "which targets exist?", "how do I map a target name to a file?"] @@ -43,6 +43,6 @@ route: ```bash ./scripts/fuzz_atheris.sh numbers --time 60 -./scripts/fuzz_atheris.sh --list +./scripts/fuzz_atheris.sh --list # stored crashes/findings, not target names ./scripts/fuzz_atheris.sh --replay runtime path/to/finding ``` diff --git a/fuzz_atheris/fuzz_iso.py b/fuzz_atheris/fuzz_iso.py index 402e62c4..a9296064 100644 --- a/fuzz_atheris/fuzz_iso.py +++ b/fuzz_atheris/fuzz_iso.py @@ -604,10 +604,17 @@ def _check_clear_multiple_components(fdp: atheris.FuzzedDataProvider) -> None: def _check_clear_unknown_component(fdp: atheris.FuzzedDataProvider) -> None: - """clear_module_caches(components=...): unknown component names silently ignored.""" + """clear_module_caches(components=...): unknown component names must fail fast.""" unknown = fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(1, 40)) or "bogus" - _ftllexengine.clear_module_caches(components=frozenset({unknown})) - # No exception must be raised for unknown component names + try: + _ftllexengine.clear_module_caches(components=frozenset({unknown})) + except ValueError as error: + if "Unknown cache component selector" not in str(error): + msg = f"Unexpected selector validation error for {unknown!r}: {error}" + raise ISOFuzzError(msg) from error + else: + msg = f"clear_module_caches accepted unknown selector {unknown!r}" + raise ISOFuzzError(msg) _domain.module_cache_clears += 1 diff --git a/pyproject.toml b/pyproject.toml index 97a41280..1a79e31f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ parser_path = "ftllexengine.syntax.parser:FluentParserV1" [project] name = "ftllexengine" -version = "0.163.0" +version = "0.164.0" description = "Python runtime for the Fluent (FTL) specification: bidirectional parsing, CLDR-backed locale-aware formatting, and fail-fast boot validation with structured audit evidence." readme = "README.md" requires-python = ">=3.13" diff --git a/scripts/run_examples.py b/scripts/run_examples.py index a8359ea9..6703167c 100644 --- a/scripts/run_examples.py +++ b/scripts/run_examples.py @@ -3,7 +3,9 @@ This keeps example verification as a first-class repository workflow instead of an ad-hoc manual step. The runner intentionally clears ``PYTHONPATH`` so -examples execute against the installed package contract, not a local path hack. +examples execute against the installed package contract, validates a registered +stdout contract for every shipped example, and rejects unregistered example +scripts from silently bypassing semantic verification. """ from __future__ import annotations @@ -12,6 +14,7 @@ import os import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -25,7 +28,59 @@ class ExampleFailure: path: Path returncode: int - stderr: str + details: str + + +ExampleContract = Callable[[str], str | None] + + +def _require_output_markers(*markers: str) -> ExampleContract: + """Build a validator that requires specific substrings in example stdout.""" + + def _validator(stdout: str) -> str | None: + missing = [marker for marker in markers if marker not in stdout] + if not missing: + return None + return f"missing expected output marker(s): {', '.join(missing)}" + + return _validator + + +EXAMPLE_CONTRACTS: dict[str, ExampleContract] = { + "benchmark_loaders.py": _require_output_markers("[OK] Benchmarks complete!"), + "bidirectional_formatting.py": _require_output_markers("All examples completed!"), + "custom_functions.py": _require_output_markers( + "[SUCCESS] All custom function examples completed!" + ), + "ftl_linter.py": _require_output_markers( + "[PASS] Clean FTL stays clean", + "[PASS] Duplicate message IDs detected", + "[PASS] Unknown functions detected", + "[PASS] Undefined message references detected", + "[PASS] Attribute-only messages flagged", + "[PASS] Undefined term references detected", + "[SUCCESS] Linter examples complete!", + ), + "ftl_transform.py": _require_output_markers("[SUCCESS] Transformer examples complete!"), + "function_introspection.py": _require_output_markers( + "[SUCCESS] All introspection examples completed!" + ), + "locale_fallback.py": _require_output_markers("[SUCCESS] All examples complete!"), + "parser_only.py": _require_output_markers( + "[PASS] Warning-only validation semantics verified", + "[PASS] Invalid syntax semantics verified", + "All examples completed successfully!", + ), + "property_based_testing.py": _require_output_markers( + "ALL PROPERTY-BASED TESTS COMPLETED" + ), + "quickstart.py": _require_output_markers( + "[SUCCESS] All examples completed successfully!" + ), + "thread_safety.py": _require_output_markers( + "[SUCCESS] All thread safety examples complete!" + ), +} def _clean_env() -> dict[str, str]: @@ -55,11 +110,23 @@ def _run_example(path: Path) -> ExampleFailure | None: timeout=60, check=False, ) - if result.returncode == 0: + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() + return ExampleFailure(path=path, returncode=result.returncode, details=stderr) + + validator = EXAMPLE_CONTRACTS.get(path.name) + if validator is None: + return ExampleFailure( + path=path, + returncode=1, + details=f"no output contract registered for {path.name}", + ) + + contract_error = validator(result.stdout) + if contract_error is None: return None - stderr = result.stderr.strip() or result.stdout.strip() - return ExampleFailure(path=path, returncode=result.returncode, stderr=stderr) + return ExampleFailure(path=path, returncode=1, details=contract_error) def main() -> int: @@ -99,7 +166,7 @@ def main() -> int: print("\n[FAIL] Example execution failures:") for failure in failures: rel_path = failure.path.relative_to(REPO_ROOT) - print(f" {rel_path} (exit {failure.returncode}): {failure.stderr}") + print(f" {rel_path} (exit {failure.returncode}): {failure.details}") return 1 print(f"[PASS] Executed {len(examples)} example script(s).") diff --git a/src/ftllexengine/__init__.py b/src/ftllexengine/__init__.py index 83e06d3e..3e21707c 100644 --- a/src/ftllexengine/__init__.py +++ b/src/ftllexengine/__init__.py @@ -16,15 +16,18 @@ FluentValue - Type alias for values accepted by formatting functions make_fluent_number - Construct FluentNumber from int/Decimal with inferred precision fluent_function - Decorator for custom functions (locale injection support) + CacheConfig - Immutable runtime cache configuration (no external dependencies) clear_module_caches - Clear all module-level caches (memory management) -Localization Boot (requires Babel): - LocalizationBootConfig - One-shot boot orchestrator for strict-mode assembly +Localization Loading (no Babel dependency): LoadSummary - Aggregate of all resource load results from initialization ResourceLoadResult - Immutable result of a single resource load attempt FallbackInfo - Immutable record of a locale fallback event ResourceLoader - Protocol for loading FTL resources (structural typing) PathResourceLoader - Disk-based loader with path-traversal prevention + +Localization Boot (requires Babel): + LocalizationBootConfig - One-shot boot orchestrator for strict-mode assembly LocalizationCacheStats - Cache statistics for all locales in a FluentLocalization Locale Utilities (no Babel dependency): @@ -36,13 +39,16 @@ normalize_locale - Convert BCP-47 to canonical lowercase POSIX form get_system_locale - Detect locale from OS environment variables -Domain Validators (no Babel dependency): - require_currency_code - Validate and normalize an ISO 4217 currency code (requires Babel) +Boundary Validators: require_date - Validate that a boundary value is a date (not datetime) require_datetime - Validate that a boundary value is a datetime require_fluent_number - Validate that a boundary value is a FluentNumber - require_locale_code - Validate and canonicalize a locale code at a system boundary - require_territory_code - Validate and normalize an ISO 3166-1 alpha-2 territory code + require_locale_code - Validate and canonicalize a locale code at a + system boundary + require_currency_code - Validate and normalize an ISO 4217 currency code + (Babel-backed) + require_territory_code - Validate and normalize an ISO 3166-1 alpha-2 + territory code (Babel-backed) Parsing Return Type (no Babel dependency): ParseResult[T] - Return type alias for parse_* functions: @@ -52,7 +58,7 @@ MessageVariableValidationResult - Structured result of variable schema validation validate_message_variables - Compare FTL message variables against expected schema -ISO Standards (Babel required at call time; importable without Babel): +ISO Standards (full-runtime CLDR data required at call time; importable in parser-only installs): CurrencyCode - ISO 4217 currency code NewType (e.g., CurrencyCode("USD")) TerritoryCode - ISO 3166-1 alpha-2 territory code NewType (e.g., TerritoryCode("US")) is_valid_currency_code - TypeIs guard: True if str is a valid ISO 4217 code (requires Babel) @@ -87,15 +93,15 @@ ftllexengine.introspection - Message introspection and variable extraction ftllexengine.parsing - Bidirectional parsing (requires Babel) ftllexengine.diagnostics - Error types and validation results - ftllexengine.localization - Resource loaders and type aliases (requires Babel) - ftllexengine.runtime - Bundle and resolver (requires Babel) + ftllexengine.localization - Resource loaders always available; FluentLocalization requires Babel + ftllexengine.runtime - Helper types always available; bundle classes require Babel ftllexengine.integrity - Data integrity exceptions (fail-fast validation) Installation: - # Parser-only (no external dependencies): + # Parser-only install: pip install ftllexengine - # Full runtime with locale formatting (requires Babel): + # Full runtime install: pip install ftllexengine[babel] """ @@ -103,8 +109,18 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _get_version +from typing import TYPE_CHECKING +from ._optional_exports import ( + ROOT_BABEL_OPTIONAL_ATTRS as _BABEL_OPTIONAL_ATTRS, +) +from ._optional_exports import ( + load_root_babel_optional_exports, + raise_missing_babel_symbol, +) from .analysis import detect_cycles +from .cache_management import clear_module_caches +from .core.babel_compat import get_cldr_version, is_babel_available from .core.locale_utils import get_system_locale, normalize_locale, require_locale_code from .core.semantic_types import FTLSource, LocaleCode, MessageId, ResourceId @@ -147,209 +163,43 @@ require_territory_code, ) from .introspection.message import MessageVariableValidationResult, validate_message_variables +from .localization.loading import ( + FallbackInfo, + LoadSummary, + PathResourceLoader, + ResourceLoader, + ResourceLoadResult, +) +from .runtime.cache_config import CacheConfig +from .runtime.function_bridge import FluentNumber, fluent_function +from .runtime.value_types import FluentValue, make_fluent_number from .syntax import parse as parse_ftl from .syntax import parse_stream as parse_stream_ftl from .syntax import serialize as serialize_ftl from .validation import validate_resource -# Babel-optional components: imported eagerly so all static analysis tools (mypy, IDEs, -# ruff) resolve the names from the import statement rather than from __getattr__ dispatch. -# On parser-only installations (no Babel) the ImportError is caught silently; __getattr__ -# then provides a clear installation hint when the caller actually accesses the name. -try: - from .core.babel_compat import ( - get_cldr_version as get_cldr_version, - ) - from .localization import ( - FallbackInfo as FallbackInfo, - ) - from .localization import ( - FluentLocalization as FluentLocalization, - ) - from .localization import ( - LoadSummary as LoadSummary, - ) - from .localization import ( - LocalizationBootConfig as LocalizationBootConfig, - ) - from .localization import ( - LocalizationCacheStats as LocalizationCacheStats, - ) - from .localization import ( - PathResourceLoader as PathResourceLoader, - ) - from .localization import ( - ResourceLoader as ResourceLoader, - ) - from .localization import ( - ResourceLoadResult as ResourceLoadResult, - ) - from .runtime import ( - AsyncFluentBundle as AsyncFluentBundle, - ) - from .runtime import ( - FluentBundle as FluentBundle, - ) - from .runtime import ( - FluentNumber as FluentNumber, - ) - from .runtime import ( - fluent_function as fluent_function, - ) - from .runtime import ( - make_fluent_number as make_fluent_number, - ) - from .runtime.cache_config import ( - CacheConfig as CacheConfig, - ) - from .runtime.value_types import ( - FluentValue as FluentValue, - ) -except ImportError: - pass # Parser-only install; __getattr__ provides the installation hint on access +if TYPE_CHECKING: + from .localization import FluentLocalization, LocalizationBootConfig, LocalizationCacheStats + from .runtime import AsyncFluentBundle, FluentBundle -_BABEL_OPTIONAL_ATTRS: frozenset[str] = frozenset({ - "AsyncFluentBundle", - "CacheConfig", - "FallbackInfo", - "FluentBundle", - "FluentNumber", - "FluentLocalization", - "FluentValue", - "LoadSummary", - "LocalizationBootConfig", - "LocalizationCacheStats", - "PathResourceLoader", - "ResourceLoadResult", - "ResourceLoader", - "fluent_function", - "make_fluent_number", - "get_cldr_version", -}) +_BABEL_AVAILABLE = is_babel_available() + +if _BABEL_AVAILABLE: + globals().update(load_root_babel_optional_exports()) def __getattr__(name: str) -> object: - """Provide a helpful ImportError for Babel-optional symbols when Babel is absent. - - Only called when Babel is NOT installed: the try/except block above did not bind - these names into the module dict. When Babel IS installed, the names are already - in globals() and Python resolves them without invoking this function. - """ - if name in _BABEL_OPTIONAL_ATTRS: - msg = ( - f"{name} requires the full runtime install (Babel + CLDR locale data). " - "Install with: pip install ftllexengine[babel]\n\n" - "For parser-only usage (no Babel required), use:\n" + """Provide a helpful missing-symbol error for Babel-backed facade symbols.""" + return raise_missing_babel_symbol( + module_name=__name__, + name=name, + optional_attrs=_BABEL_OPTIONAL_ATTRS, + parser_only_hint=( + "For parser-only installs, use:\n" " from ftllexengine.syntax import parse, serialize\n" " from ftllexengine.syntax.ast import Message, Term, Pattern, ..." - ) - raise ImportError(msg) - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) - - -def clear_module_caches( - components: frozenset[str] | None = None, -) -> None: - """Clear module-level caches in the library. - - Provides unified cache management for long-running applications. With - ``components=None`` (the default), clears all caches: - - - ``'parsing.currency'``: CLDR currency data caches - - ``'parsing.dates'``: CLDR date/datetime pattern caches - - ``'locale'``: Babel locale object cache (locale_utils) - - ``'runtime.locale_context'``: LocaleContext instance cache - - ``'introspection.message'``: Message introspection result cache - - ``'introspection.iso'``: ISO territory/currency introspection cache - - Pass a ``frozenset`` of component names to clear only specific caches. - This is useful when certain caches (e.g., Babel locale data) are expensive - to repopulate and should not be cleared during routine periodic trimming. - - Args: - components: Set of component names to clear. When ``None``, clears all - caches. Known component names: ``'parsing.currency'``, - ``'parsing.dates'``, ``'locale'``, ``'runtime.locale_context'``, - ``'introspection.message'``, ``'introspection.iso'``. - Unknown component names are silently ignored. - - Useful for: - - Memory reclamation in long-running server applications - - Testing scenarios requiring fresh cache state - - After Babel/CLDR data updates - - Thread-safe. Each underlying cache uses its own locking mechanism. - - Note: - This function does NOT require Babel. It clears caches regardless - of whether Babel-dependent modules have been imported. Caches that - haven't been populated yet are simply no-ops. - - FluentBundle instances maintain their own IntegrityCache which is NOT - cleared by this function. To clear a bundle's format cache, call - ``bundle.clear_cache()``. - - Example: - >>> import ftllexengine # doctest: +SKIP - >>> ftllexengine.clear_module_caches() # Clear all caches # doctest: +SKIP - >>> ftllexengine.clear_module_caches( # Clear only ISO + message caches # doctest: +SKIP - ... components=frozenset({'introspection.iso', 'introspection.message'}) - ... ) - """ - # Import and clear each cache module. - # Order: parsing caches first (depend on locale cache), then locale, then introspection. - # Parsing and runtime caches are conditionally cleared: they are Babel-dependent and - # may not have been imported in parser-only installations. Skipping an unimported module - # is semantically correct — an unimported module has no populated cache to clear. - - # When components is None (clear all), use an empty sentinel so that every - # `_want()` call short-circuits via clear_all without inspecting the set. - clear_all = components is None - _comps: frozenset[str] = frozenset() if components is None else components - - def _want(name: str) -> bool: - return clear_all or name in _comps - - # 1. Parsing caches (Babel-dependent: only present in full-runtime installations) - if _want("parsing.currency"): - try: - from .parsing.currency import clear_currency_caches - clear_currency_caches() - except ImportError: # pragma: no cover - pass # Parser-only installation; parsing.currency never imported - - if _want("parsing.dates"): - try: - from .parsing.dates import clear_date_caches - clear_date_caches() - except ImportError: # pragma: no cover - pass # Parser-only installation; parsing.dates never imported - - # 2. Locale caches (always present: core.locale_utils has no Babel dep at module level) - if _want("locale"): - from .core.locale_utils import clear_locale_cache - - clear_locale_cache() - - # 3. Runtime locale context (Babel-dependent) - if _want("runtime.locale_context"): - try: - from .runtime.locale_context import LocaleContext - LocaleContext.clear_cache() - except ImportError: # pragma: no cover - pass # Parser-only installation; runtime.locale_context never imported - - # 4. Introspection caches (message introspection + ISO standards data) - if _want("introspection.message"): - from .introspection import clear_introspection_cache - - clear_introspection_cache() - - if _want("introspection.iso"): - from .introspection import clear_iso_cache - - clear_iso_cache() + ), + ) # Version information - Auto-populated from package metadata @@ -370,8 +220,9 @@ def _want(name: str) -> bool: # ruff: noqa: RUF022 - __all__ organized by category for readability, not alphabetically __all__ = [ - # Bundle and Localization (Babel-optional; absent in parser-only installs) + # Babel-backed facades "AsyncFluentBundle", + # Runtime helpers and localization loading (parser-only safe) "CacheConfig", "FallbackInfo", "FluentBundle", @@ -421,7 +272,7 @@ def _want(name: str) -> bool: # Introspection (no Babel dependency) "MessageVariableValidationResult", "validate_message_variables", - # ISO standards (importable without Babel; most raise BabelImportError when called) + # ISO standards (importable in parser-only installs; most validate via Babel at call time) "CurrencyCode", "TerritoryCode", "get_cldr_version", @@ -445,3 +296,6 @@ def _want(name: str) -> bool: "__spec_url__", "__version__", ] + +if not _BABEL_AVAILABLE: + __all__ = [name for name in __all__ if name not in _BABEL_OPTIONAL_ATTRS] diff --git a/src/ftllexengine/__init__.pyi b/src/ftllexengine/__init__.pyi index f1af4a18..bdc7966d 100644 --- a/src/ftllexengine/__init__.pyi +++ b/src/ftllexengine/__init__.pyi @@ -1,4 +1,4 @@ -# ISO data utilities (require Babel) +# ISO data utilities (call-time Babel requirement) from .analysis import detect_cycles as detect_cycles from .core.babel_compat import get_cldr_version as get_cldr_version @@ -76,15 +76,17 @@ from .introspection.message import ( ) from .introspection.message import validate_message_variables as validate_message_variables -# Localization and runtime (requires Babel) +# Localization loading and runtime helpers (parser-only safe) from .localization import FallbackInfo as FallbackInfo -from .localization import FluentLocalization as FluentLocalization from .localization import LoadSummary as LoadSummary -from .localization import LocalizationBootConfig as LocalizationBootConfig -from .localization import LocalizationCacheStats as LocalizationCacheStats from .localization import PathResourceLoader as PathResourceLoader from .localization import ResourceLoader as ResourceLoader from .localization import ResourceLoadResult as ResourceLoadResult + +# Babel-backed facades +from .localization.boot import LocalizationBootConfig as LocalizationBootConfig +from .localization.orchestrator import FluentLocalization as FluentLocalization +from .localization.orchestrator import LocalizationCacheStats as LocalizationCacheStats from .runtime import AsyncFluentBundle as AsyncFluentBundle from .runtime import FluentBundle as FluentBundle from .runtime import FluentNumber as FluentNumber @@ -113,17 +115,12 @@ __recommended_encoding__: str # Explicit __all__ for mypy to recognize re-exports # ruff: noqa: RUF022 - __all__ organized by category for readability, not alphabetically __all__: list[str] = [ - # Bundle and Localization (Babel-optional; absent in parser-only installs) - "AsyncFluentBundle", + # Runtime helpers and localization loading (parser-only safe) "CacheConfig", "FallbackInfo", - "FluentBundle", "FluentNumber", - "FluentLocalization", "FluentValue", "LoadSummary", - "LocalizationBootConfig", - "LocalizationCacheStats", "PathResourceLoader", "ResourceLoadResult", "ResourceLoader", @@ -187,4 +184,10 @@ __all__: list[str] = [ "__recommended_encoding__", "__spec_url__", "__version__", + # Babel-backed facades + "AsyncFluentBundle", + "FluentBundle", + "FluentLocalization", + "LocalizationBootConfig", + "LocalizationCacheStats", ] diff --git a/src/ftllexengine/_optional_exports.py b/src/ftllexengine/_optional_exports.py new file mode 100644 index 00000000..4e4d6354 --- /dev/null +++ b/src/ftllexengine/_optional_exports.py @@ -0,0 +1,134 @@ +"""Helpers for Babel-backed facade exports. + +Centralizes facade wiring for symbols that genuinely require the Babel-enabled +runtime at import time. Zero-dependency symbols should be imported directly by +their facade modules instead of being routed through this helper. +""" + +from __future__ import annotations + +from typing import NoReturn + +ROOT_BABEL_OPTIONAL_ATTRS: frozenset[str] = frozenset({ + "AsyncFluentBundle", + "FluentBundle", + "FluentLocalization", + "LocalizationBootConfig", + "LocalizationCacheStats", +}) + +LOCALIZATION_BABEL_OPTIONAL_ATTRS: frozenset[str] = frozenset({ + "FluentLocalization", + "LocalizationBootConfig", + "LocalizationCacheStats", +}) + +RUNTIME_BABEL_OPTIONAL_ATTRS: frozenset[str] = frozenset({ + "AsyncFluentBundle", + "create_default_registry", + "currency_format", + "datetime_format", + "FluentBundle", + "get_shared_registry", + "number_format", + "select_plural_category", +}) + + +def load_root_babel_optional_exports() -> dict[str, object]: + """Return root-facade exports that require the Babel-enabled runtime.""" + from .localization.boot import ( # noqa: PLC0415 - intentionally deferred optional import + LocalizationBootConfig, + ) + from .localization.orchestrator import ( # noqa: PLC0415 - intentionally deferred optional import + FluentLocalization, + LocalizationCacheStats, + ) + from .runtime.async_bundle import ( # noqa: PLC0415 - intentionally deferred optional import + AsyncFluentBundle, + ) + from .runtime.bundle import ( # noqa: PLC0415 - intentionally deferred optional import + FluentBundle, + ) + + return { + "AsyncFluentBundle": AsyncFluentBundle, + "FluentBundle": FluentBundle, + "FluentLocalization": FluentLocalization, + "LocalizationBootConfig": LocalizationBootConfig, + "LocalizationCacheStats": LocalizationCacheStats, + } + + +def load_localization_babel_optional_exports() -> dict[str, object]: + """Return localization-facade exports that require the Babel runtime.""" + from ftllexengine.localization.boot import ( # noqa: PLC0415 - intentionally deferred optional import + LocalizationBootConfig, + ) + from ftllexengine.localization.orchestrator import ( # noqa: PLC0415 - intentionally deferred optional import + FluentLocalization, + LocalizationCacheStats, + ) + + return { + "FluentLocalization": FluentLocalization, + "LocalizationBootConfig": LocalizationBootConfig, + "LocalizationCacheStats": LocalizationCacheStats, + } + + +def load_runtime_babel_optional_exports() -> dict[str, object]: + """Return runtime-facade exports that require the Babel runtime.""" + from .runtime.async_bundle import ( # noqa: PLC0415 - intentionally deferred optional import + AsyncFluentBundle, + ) + from .runtime.bundle import ( # noqa: PLC0415 - intentionally deferred optional import + FluentBundle, + ) + from .runtime.functions import ( # noqa: PLC0415 - intentionally deferred optional import + create_default_registry, + currency_format, + datetime_format, + get_shared_registry, + number_format, + ) + from .runtime.plural_rules import ( # noqa: PLC0415 - intentionally deferred optional import + select_plural_category, + ) + + return { + "AsyncFluentBundle": AsyncFluentBundle, + "create_default_registry": create_default_registry, + "currency_format": currency_format, + "datetime_format": datetime_format, + "FluentBundle": FluentBundle, + "get_shared_registry": get_shared_registry, + "number_format": number_format, + "select_plural_category": select_plural_category, + } + + +def raise_missing_babel_symbol( + *, + module_name: str, + name: str, + optional_attrs: frozenset[str], + parser_only_hint: str | None = None, +) -> NoReturn: + """Raise a helpful AttributeError for a Babel-backed optional symbol. + + Module attribute access uses ``AttributeError`` so Python feature probes + such as ``hasattr()`` and ``getattr(..., default)`` treat the symbol as + absent in parser-only installs. + """ + if name in optional_attrs: + message = ( + f"{name} requires the full runtime install (Babel + CLDR locale data). " + "Install with: pip install ftllexengine[babel]" + ) + if parser_only_hint is not None: + message = f"{message}\n\n{parser_only_hint}" + raise AttributeError(message) + + message = f"module {module_name!r} has no attribute {name!r}" + raise AttributeError(message) diff --git a/src/ftllexengine/cache_management.py b/src/ftllexengine/cache_management.py new file mode 100644 index 00000000..aaf4f7b0 --- /dev/null +++ b/src/ftllexengine/cache_management.py @@ -0,0 +1,145 @@ +"""Unified cache-clearing helpers for module-level caches. + +This module backs the public ``ftllexengine.clear_module_caches()`` facade. +It validates component selectors before clearing caches so cache maintenance +fails explicitly on typos instead of silently leaving stale state behind. +""" + +from __future__ import annotations + +from typing import Literal + +from .core.babel_compat import is_babel_available + +type CacheComponentName = Literal[ + "parsing.currency", + "parsing.dates", + "locale", + "runtime.locale_context", + "introspection.message", + "introspection.iso", +] + +_KNOWN_CACHE_COMPONENTS: frozenset[CacheComponentName] = frozenset({ + "introspection.iso", + "introspection.message", + "locale", + "parsing.currency", + "parsing.dates", + "runtime.locale_context", +}) + +__all__ = ["clear_module_caches"] + + +def _validate_cache_components(components: frozenset[str] | None) -> frozenset[str] | None: + """Validate requested cache component selectors. + + Raises: + ValueError: If any selector is unknown. + """ + if components is None: + return None + + unknown = sorted(set(components) - set(_KNOWN_CACHE_COMPONENTS)) + if not unknown: + return components + + unknown_display = ", ".join(repr(name) for name in unknown) + known_display = ", ".join(repr(name) for name in sorted(_KNOWN_CACHE_COMPONENTS)) + msg = ( + "Unknown cache component selector(s): " + f"{unknown_display}. Known selectors: {known_display}" + ) + raise ValueError(msg) + + +def clear_module_caches( + components: frozenset[str] | None = None, +) -> None: + """Clear module-level caches in the library. + + Provides unified cache management for long-running applications. With + ``components=None`` (the default), clears all caches: + + - ``'parsing.currency'``: CLDR currency data caches + - ``'parsing.dates'``: CLDR date/datetime pattern caches + - ``'locale'``: Babel locale object cache (locale_utils) + - ``'runtime.locale_context'``: LocaleContext instance cache + - ``'introspection.message'``: Message introspection result cache + - ``'introspection.iso'``: ISO territory/currency introspection cache + + Pass a ``frozenset`` of component names to clear only specific caches. + This is useful when certain caches (for example Babel locale data) are + expensive to repopulate and should not be cleared during routine trimming. + + Args: + components: Set of component names to clear. When ``None``, clears all + caches. Unknown names raise ValueError so selector typos fail fast. + + Useful for: + - Memory reclamation in long-running server applications + - Testing scenarios requiring fresh cache state + - After Babel/CLDR data updates + + Thread-safe. Each underlying cache uses its own locking mechanism. + + Note: + This function does NOT require Babel. It clears caches regardless + of whether Babel-dependent modules have been imported. Caches that + have not been populated yet are simply no-ops. + + FluentBundle instances maintain their own IntegrityCache which is NOT + cleared by this function. To clear a bundle's format cache, call + ``bundle.clear_cache()``. + """ + validated = _validate_cache_components(components) + babel_available = is_babel_available() + + clear_all = validated is None + selected: frozenset[str] = frozenset() if validated is None else validated + + def _want(name: CacheComponentName) -> bool: + return clear_all or name in selected + + if babel_available and _want("parsing.currency"): + from .parsing.currency import ( # noqa: PLC0415 - imported only when Babel is available + clear_currency_caches, + ) + + clear_currency_caches() + + if babel_available and _want("parsing.dates"): + from .parsing.dates import ( # noqa: PLC0415 - imported only when Babel is available + clear_date_caches, + ) + + clear_date_caches() + + if _want("locale"): + from .core.locale_utils import ( # noqa: PLC0415 - imported only when cache clearing runs + clear_locale_cache, + ) + + clear_locale_cache() + + if babel_available and _want("runtime.locale_context"): + from .runtime.locale_context import ( # noqa: PLC0415 - imported only when Babel is available + LocaleContext, + ) + + LocaleContext.clear_cache() + + if _want("introspection.message"): + from .introspection import ( # noqa: PLC0415 - imported only when cache clearing runs + clear_introspection_cache, + ) + + clear_introspection_cache() + + if _want("introspection.iso"): + from .introspection import ( # noqa: PLC0415 - imported only when cache clearing runs + clear_iso_cache, + ) + + clear_iso_cache() diff --git a/src/ftllexengine/core/babel_compat.py b/src/ftllexengine/core/babel_compat.py index defd360f..28697d2e 100644 --- a/src/ftllexengine/core/babel_compat.py +++ b/src/ftllexengine/core/babel_compat.py @@ -62,6 +62,16 @@ def my_function(locale_code: str) -> None: _babel_available: bool | None = None +def _is_missing_top_level_babel(exc: ImportError) -> bool: + """Return True only for a genuinely missing top-level Babel package.""" + if isinstance(exc, ModuleNotFoundError): + missing_name = exc.name + if isinstance(missing_name, str): + return missing_name == "babel" + + return str(exc) == "No module named 'babel'" + + def _check_babel_available() -> bool: """Check if Babel is installed (computed once, cached via sentinel).""" global _babel_available # noqa: PLW0603 - module-level sentinel, single write per process @@ -70,7 +80,9 @@ def _check_babel_available() -> bool: import babel # noqa: F401, PLC0415 - sentinel check; import is the check itself _babel_available = True - except ImportError: + except ImportError as exc: + if not _is_missing_top_level_babel(exc): + raise _babel_available = False return _babel_available diff --git a/src/ftllexengine/diagnostics/__init__.py b/src/ftllexengine/diagnostics/__init__.py index db910bb2..1530ba27 100644 --- a/src/ftllexengine/diagnostics/__init__.py +++ b/src/ftllexengine/diagnostics/__init__.py @@ -18,6 +18,7 @@ from .formatter import DiagnosticFormatter, OutputFormat from .templates import ErrorTemplate from .validation import ( + ParserAnnotation, ValidationError, ValidationResult, ValidationWarning, @@ -40,6 +41,7 @@ "ParseResult", "ParseTypeLiteral", # Validation + "ParserAnnotation", "ValidationError", "ValidationResult", "ValidationWarning", diff --git a/src/ftllexengine/diagnostics/validation.py b/src/ftllexengine/diagnostics/validation.py index 0b95c28a..62cedf3b 100644 --- a/src/ftllexengine/diagnostics/validation.py +++ b/src/ftllexengine/diagnostics/validation.py @@ -25,6 +25,7 @@ from .formatter import DiagnosticFormatter __all__ = [ + "ParserAnnotation", "ValidationError", "ValidationResult", "ValidationWarning", @@ -274,7 +275,7 @@ def annotation_count(self) -> int: """Get number of parser-level annotations (parse errors). Returns: - Count of Annotation instances in ``annotations`` + Count of parser annotations in ``annotations`` """ return len(self.annotations) diff --git a/src/ftllexengine/introspection/iso.py b/src/ftllexengine/introspection/iso.py index 962e8d16..fafe6e90 100644 --- a/src/ftllexengine/introspection/iso.py +++ b/src/ftllexengine/introspection/iso.py @@ -7,38 +7,20 @@ Requires Babel installation for full functionality: pip install ftllexengine[babel] -Without Babel, functions raise BabelImportError with installation guidance. +Parser-only installs may still use `get_currency_decimal_digits()` because it +reads embedded ISO 4217 tables instead of CLDR locale data. Python 3.13+. Babel is optional dependency. """ from __future__ import annotations -from functools import lru_cache - -# TypeIs (PEP 742) is available unconditionally on Python 3.13+, which is the -# minimum supported version. The import is placed here at module level so that -# typing.get_type_hints() callers resolve the name from this module's globals. -from typing import TypeIs - -from ftllexengine.constants import ( - ISO_4217_DECIMAL_DIGITS, - ISO_4217_DEFAULT_DECIMALS, - ISO_4217_VALID_CODES, - MAX_CURRENCY_CACHE_SIZE, - MAX_LOCALE_CACHE_SIZE, - MAX_TERRITORY_CACHE_SIZE, -) +# ruff: noqa: SLF001 - facade intentionally re-exports tested private cache helpers from ftllexengine.core.babel_compat import BabelImportError -from ftllexengine.core.locale_utils import normalize_locale -from ftllexengine.introspection.iso_babel import ( - _get_babel_currencies, - _get_babel_currency_name, - _get_babel_currency_symbol, - _get_babel_official_languages, - _get_babel_territories, - _get_babel_territory_currencies, -) +from ftllexengine.introspection import iso_babel as _iso_babel +from ftllexengine.introspection import iso_lookup as _iso_lookup +from ftllexengine.introspection import iso_validation as _iso_validation +from ftllexengine.introspection.iso_cache import clear_iso_cache from ftllexengine.introspection.iso_types import ( CurrencyCode, CurrencyInfo, @@ -46,6 +28,32 @@ TerritoryInfo, ) +_get_babel_currencies = _iso_babel._get_babel_currencies +_get_babel_currency_name = _iso_babel._get_babel_currency_name +_get_babel_currency_symbol = _iso_babel._get_babel_currency_symbol +_get_babel_official_languages = _iso_babel._get_babel_official_languages +_get_babel_territories = _iso_babel._get_babel_territories +_get_babel_territory_currencies = _iso_babel._get_babel_territory_currencies + +_get_currency_impl = _iso_lookup._get_currency_impl +_get_territory_currencies_impl = _iso_lookup._get_territory_currencies_impl +_get_territory_impl = _iso_lookup._get_territory_impl +_list_currencies_impl = _iso_lookup._list_currencies_impl +_list_territories_impl = _iso_lookup._list_territories_impl +get_currency = _iso_lookup.get_currency +get_currency_decimal_digits = _iso_lookup.get_currency_decimal_digits +get_territory = _iso_lookup.get_territory +get_territory_currencies = _iso_lookup.get_territory_currencies +list_currencies = _iso_lookup.list_currencies +list_territories = _iso_lookup.list_territories + +_currency_codes_impl = _iso_validation._currency_codes_impl +_territory_codes_impl = _iso_validation._territory_codes_impl +is_valid_currency_code = _iso_validation.is_valid_currency_code +is_valid_territory_code = _iso_validation.is_valid_territory_code +require_currency_code = _iso_validation.require_currency_code +require_territory_code = _iso_validation.require_territory_code + # ruff: noqa: RUF022 - __all__ organized by category for readability __all__ = [ # NewType wrappers @@ -72,597 +80,3 @@ # Exceptions "BabelImportError", ] - -# CACHED LOOKUP FUNCTIONS -# ============================================================================ - - -@lru_cache(maxsize=MAX_TERRITORY_CACHE_SIZE) -def _get_territory_impl( - code_upper: str, - locale_norm: str, -) -> TerritoryInfo | None: - """Internal cached implementation for get_territory. - - Args: - code_upper: Pre-uppercased ISO 3166-1 alpha-2 code. - locale_norm: Pre-normalized locale string. - - Returns: - TerritoryInfo if found, None if unknown code. - """ - territories = _get_babel_territories(locale_norm) - - if code_upper not in territories: - return None - - name = territories[code_upper] - currencies = get_territory_currencies(code_upper) - official_languages = _get_babel_official_languages(code_upper) - - return TerritoryInfo( - alpha2=TerritoryCode(code_upper), - name=name, - currencies=currencies, - official_languages=official_languages, - ) - - -def get_territory( - code: str, - locale: str = "en", -) -> TerritoryInfo | None: - """Look up ISO 3166-1 territory by alpha-2 code. - - Args: - code: ISO 3166-1 alpha-2 code (e.g., 'US', 'LV'). Case-insensitive. - locale: Locale for name localization (default: 'en'). Accepts BCP-47 - (en-US) or POSIX (en_US) formats; normalized internally. - - Returns: - TerritoryInfo if found, None if unknown code. - - Raises: - BabelImportError: If Babel not installed. - - Thread-safe. Results cached per normalized (code, locale) pair. - """ - # Guard: ISO 3166-1 alpha-2 codes are exactly 2 characters before uppercasing. - # str.upper() can expand single characters (e.g., 'ß' → 'SS'), so checking the - # raw length prevents a length-1 input from matching a valid 2-char territory code - # via Unicode casefold expansion. This keeps get_territory consistent with the - # is_valid_territory_code type guard, which also checks len(value) == 2. - if len(code) != 2: - return None - return _get_territory_impl(code.upper(), normalize_locale(locale)) - - -@lru_cache(maxsize=MAX_CURRENCY_CACHE_SIZE) -def _get_currency_impl( - code_upper: str, - locale_norm: str, -) -> CurrencyInfo | None: - """Internal cached implementation for get_currency. - - Args: - code_upper: Pre-uppercased ISO 4217 currency code. - locale_norm: Pre-normalized locale string. - - Returns: - CurrencyInfo if found, None if unknown code. - """ - name = _get_babel_currency_name(code_upper, locale_norm) - - if name is None: - return None - - symbol = _get_babel_currency_symbol(code_upper, locale_norm) - - # ISO 4217 standard is the authoritative source for currency decimal precision. - # Babel's CLDR data may differ from ISO 4217 for specific currencies (e.g., IQD: - # Babel reports 0 decimals, ISO 4217 specifies 3). For financial-grade accuracy, - # the hardcoded ISO 4217 data is used as the source of truth. - decimal_digits = ISO_4217_DECIMAL_DIGITS.get(code_upper, ISO_4217_DEFAULT_DECIMALS) - - return CurrencyInfo( - code=CurrencyCode(code_upper), - name=name, - symbol=symbol, - decimal_digits=decimal_digits, - ) - - -def get_currency( - code: str, - locale: str = "en", -) -> CurrencyInfo | None: - """Look up ISO 4217 currency by code. - - Args: - code: ISO 4217 currency code (e.g., 'USD', 'EUR'). Case-insensitive. - locale: Locale for name/symbol localization (default: 'en'). Accepts - BCP-47 (en-US) or POSIX (en_US) formats; normalized internally. - - Returns: - CurrencyInfo if found, None if unknown code. - - Raises: - BabelImportError: If Babel not installed. - - Thread-safe. Results cached per normalized (code, locale) pair. - """ - # Guard: ISO 4217 currency codes are exactly 3 characters before uppercasing. - # str.upper() can expand single characters (e.g., 'ß' → 'SS'), so a 2-char - # input could produce a valid 3-char code via casefold expansion. Checking the - # raw length keeps get_currency consistent with is_valid_currency_code. - if len(code) != 3: - return None - return _get_currency_impl(code.upper(), normalize_locale(locale)) - - -def get_currency_decimal_digits(code: str) -> int | None: - """Return ISO 4217 standard decimal precision for a currency code. - - Babel-free: queries the embedded ISO 4217 tables directly without any - locale lookup. No Babel installation required. Safe to call in - parser-only installs (``pip install ftllexengine`` without ``[babel]``). - - Decimal precision is a currency-level ISO 4217 property, not a locale - property: KWD always has 3 decimal places, JPY always has 0, USD/EUR - always have 2, regardless of display locale. - - Validation is against ``ISO_4217_VALID_CODES``, the embedded authoritative - code set. Only codes present in that set yield a precision value. Codes - not listed return None regardless of Babel availability. - - **ISO 4217 vs CLDR divergence:** This function follows the ISO 4217 - standard, not Babel's CLDR usage data. The two sources differ for - some currencies where minor units exist in the standard but are not - used in practice: - - - ``IQD`` (Iraqi Dinar): ISO 4217 specifies **3** decimal places (fils). - Babel/CLDR reports 0 because fils are not used in daily commerce. - Callers mixing this function with ``babel.numbers.get_currency_precision`` - will observe a discrepancy for IQD. This function returns the - ISO-standard value (3). - - ``MGA`` (Malagasy Ariary): ISO 4217 assigns exponent 2, but the actual - subdivision is 1/5 (1 ariary = 5 iraimbilanja), not 1/100. This - function returns 2 per the ISO standard; financial systems formatting - MGA should be aware that the subdivision is non-decimal. - - Args: - code: ISO 4217 currency code (e.g., 'USD', 'EUR'). Case-insensitive. - - Returns: - Number of decimal digits (0 for JPY, 2 for USD/EUR, 3 for KWD), or - None if the code is not a currently active ISO 4217 currency code. - Historical or retired codes (e.g. SLL, ZWL, TMM, TRL) return None — - they are absent from ``ISO_4217_VALID_CODES``, which covers only active - standards. Babel's ``get_currency()`` may still return data for these - historical codes via CLDR; the two functions have different scopes by - design. - - Thread-safe. No Babel dependency. O(1) constant-time lookup against - process-immutable tables. - - Examples: - >>> get_currency_decimal_digits("KWD") # doctest: +SKIP - 3 - >>> get_currency_decimal_digits("JPY") # doctest: +SKIP - 0 - >>> get_currency_decimal_digits("EUR") # doctest: +SKIP - 2 - >>> get_currency_decimal_digits("IQD") # doctest: +SKIP - 3 - >>> get_currency_decimal_digits("XYZ") is None # doctest: +SKIP - True - """ - # ISO 4217 codes are exactly 3 characters before uppercasing. - # Mirrors the raw-length guard in get_currency() to prevent casefold expansion. - if len(code) != 3: - return None - code_upper = code.upper() - if code_upper not in ISO_4217_VALID_CODES: - return None - return ISO_4217_DECIMAL_DIGITS.get(code_upper, ISO_4217_DEFAULT_DECIMALS) - - -@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) -def _list_territories_impl( - locale_norm: str, -) -> frozenset[TerritoryInfo]: - """Internal cached implementation for list_territories. - - Args: - locale_norm: Pre-normalized locale string. - - Returns: - Frozen set of all TerritoryInfo objects. - """ - territories = _get_babel_territories(locale_norm) - result: set[TerritoryInfo] = set() - - for code, name in territories.items(): - # Filter to alpha-2 codes only (2 uppercase letters) - if len(code) == 2 and code.isalpha() and code.isupper(): - currencies = get_territory_currencies(code) - official_languages = _get_babel_official_languages(code) - result.add( - TerritoryInfo( - alpha2=TerritoryCode(code), - name=name, - currencies=currencies, - official_languages=official_languages, - ) - ) - - return frozenset(result) - - -def list_territories( - locale: str = "en", -) -> frozenset[TerritoryInfo]: - """List all known ISO 3166-1 territories. - - Args: - locale: Locale for name localization (default: 'en'). Accepts BCP-47 - (en-US) or POSIX (en_US) formats; normalized internally. - - Returns: - Frozen set of all TerritoryInfo objects. - - Raises: - BabelImportError: If Babel not installed. - - Thread-safe. Result cached per normalized locale. - """ - return _list_territories_impl(normalize_locale(locale)) - - -@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) -def _list_currencies_impl( - locale_norm: str, -) -> frozenset[CurrencyInfo]: - """Internal cached implementation for list_currencies. - - Returns complete ISO 4217 currency set regardless of locale. When a currency - lacks a localized name in the target locale, falls back to English name. - This ensures consistent result sets across locales for financial applications. - - Args: - locale_norm: Pre-normalized locale string. - - Returns: - Frozen set of all CurrencyInfo objects. - """ - # Get all currency codes and English names from Babel - currencies_en = _get_babel_currencies() - result: set[CurrencyInfo] = set() - - for code, english_name in currencies_en.items(): - # Filter to valid ISO 4217 codes (3 uppercase letters) - if len(code) == 3 and code.isalpha() and code.isupper(): - # Try to get localized info - info = _get_currency_impl(code, locale_norm) - if info is not None: - result.add(info) - else: - # Fallback: use English name when localized name unavailable - # This ensures complete currency list regardless of locale coverage - symbol = _get_babel_currency_symbol(code, locale_norm) - decimal_digits = ISO_4217_DECIMAL_DIGITS.get( - code, ISO_4217_DEFAULT_DECIMALS - ) - result.add( - CurrencyInfo( - code=CurrencyCode(code), - name=english_name, - symbol=symbol, - decimal_digits=decimal_digits, - ) - ) - - return frozenset(result) - - -def list_currencies( - locale: str = "en", -) -> frozenset[CurrencyInfo]: - """List all known ISO 4217 currencies. - - Returns the complete ISO 4217 currency set regardless of locale. Currencies - are localized where CLDR data is available; otherwise, English names are - used as fallback. This ensures consistent result sets across all locales - for financial applications. - - Args: - locale: Locale for name/symbol localization (default: 'en'). Accepts - BCP-47 (en-US) or POSIX (en_US) formats; normalized internally. - - Returns: - Frozen set of all CurrencyInfo objects. The set is complete and - consistent regardless of locale - same currencies returned for - all locales, only names/symbols differ based on CLDR coverage. - - Raises: - BabelImportError: If Babel not installed. - - Thread-safe. Result cached per normalized locale. - """ - return _list_currencies_impl(normalize_locale(locale)) - - -@lru_cache(maxsize=MAX_TERRITORY_CACHE_SIZE) -def _get_territory_currencies_impl(territory_upper: str) -> tuple[CurrencyCode, ...]: - """Internal cached implementation for get_territory_currencies. - - Args: - territory_upper: Pre-uppercased ISO 3166-1 alpha-2 code. - - Returns: - Tuple of all active legal tender ISO 4217 currency codes. - Empty tuple if territory unknown or has no currency data. - """ - currencies = _get_babel_territory_currencies(territory_upper) - return tuple(CurrencyCode(c) for c in currencies) - - -def get_territory_currencies(territory: str) -> tuple[CurrencyCode, ...]: - """Get all active legal tender currencies for a territory. - - Multi-currency territories (e.g., Panama with PAB and USD) return - all currencies currently in use. The order reflects CLDR precedence - (typically the most commonly used currency first). - - Args: - territory: ISO 3166-1 alpha-2 code. Case-insensitive. - - Returns: - Tuple of all active ISO 4217 currency codes for the territory. - Empty tuple if territory unknown or has no currency data. - - Raises: - BabelImportError: If Babel not installed. - - Thread-safe. Result cached per normalized territory code. - """ - # Guard: ISO 3166-1 alpha-2 codes are exactly 2 characters before uppercasing. - # Mirrors the guard in get_territory to prevent casefold expansion mismatches. - if len(territory) != 2: - return () - return _get_territory_currencies_impl(territory.upper()) - - -# ============================================================================ -# VALIDATION CODE SETS (Cache Pollution Prevention) -# ============================================================================ -# -# Security Design: Validation functions must NOT call get_territory/get_currency -# because those functions cache individual lookup results (including None for -# invalid codes). An attacker could fill the LRU cache with None entries by -# validating random strings, evicting legitimate cached lookups. -# -# Solution: Validation uses membership checks against pre-cached code sets. -# The _list_territories_impl/_list_currencies_impl functions cache the COMPLETE -# set once per locale, so validation queries hit this single cached set without -# polluting the individual lookup caches. -# -# ============================================================================ - - -@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) -def _territory_codes_impl(locale_norm: str) -> frozenset[str]: - """Internal cached implementation returning all valid territory codes. - - Extracts alpha-2 codes from the full territory list for O(1) validation. - Cached per locale because territory codes are locale-independent, but the - underlying _list_territories_impl is locale-keyed. - - Args: - locale_norm: Pre-normalized locale string. - - Returns: - Frozen set of all valid ISO 3166-1 alpha-2 codes. - """ - return frozenset(t.alpha2 for t in _list_territories_impl(locale_norm)) - - -@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) -def _currency_codes_impl(locale_norm: str) -> frozenset[str]: - """Internal cached implementation returning all valid currency codes. - - Extracts currency codes from the full currency list for O(1) validation. - Cached per locale because currency codes are locale-independent, but the - underlying _list_currencies_impl is locale-keyed. - - Args: - locale_norm: Pre-normalized locale string. - - Returns: - Frozen set of all valid ISO 4217 currency codes. - """ - return frozenset(c.code for c in _list_currencies_impl(locale_norm)) - - -# ============================================================================ -# TYPE GUARDS (PEP 742) -# ============================================================================ - - -def is_valid_territory_code(value: str) -> TypeIs[TerritoryCode]: - """Check if string is a valid ISO 3166-1 alpha-2 code. - - Validates against Babel's CLDR territory database using O(1) set membership. - Does NOT cache invalid inputs (cache pollution prevention). - - Args: - value: String to check. - - Returns: - True if value is a known ISO 3166-1 alpha-2 code. - - Raises: - BabelImportError: If Babel not installed. - """ - if not isinstance(value, str) or len(value) != 2: - return False - # Use membership check against cached code set (prevents cache pollution). - # normalize_locale("en") is used because territory codes are locale-independent; - # we just need any valid locale to trigger the Babel lookup once. - return value.upper() in _territory_codes_impl(normalize_locale("en")) - - -def is_valid_currency_code(value: str) -> TypeIs[CurrencyCode]: - """Check if string is a valid ISO 4217 currency code. - - Validates against Babel's CLDR currency database using O(1) set membership. - Does NOT cache invalid inputs (cache pollution prevention). - - Args: - value: String to check. - - Returns: - True if value is a known ISO 4217 currency code. - - Raises: - BabelImportError: If Babel not installed. - """ - if not isinstance(value, str) or len(value) != 3: - return False - # Use membership check against cached code set (prevents cache pollution). - # normalize_locale("en") is used because currency codes are locale-independent; - # we just need any valid locale to trigger the Babel lookup once. - return value.upper() in _currency_codes_impl(normalize_locale("en")) - - -# ============================================================================ -# CACHE MANAGEMENT -# ============================================================================ - - -def require_currency_code(value: object, field_name: str) -> CurrencyCode: - """Validate and normalize a boundary value to a canonical ISO 4217 currency code. - - Strips surrounding whitespace, rejects non-str types with TypeError, normalizes - to uppercase, and validates against Babel's CLDR currency database. Returns the - canonical uppercase CurrencyCode so callers need no post-validation normalization. - - Use at every constructor or API entry point that accepts a currency code field. - Eliminates the require_non_empty_str + upper() + is_valid_currency_code chain - that every downstream system would otherwise reimplement independently. - - Args: - value: Raw boundary value to validate. Accepts any Python object; non-str - values always raise TypeError; values that are not valid ISO 4217 codes - raise ValueError. - field_name: Human-readable field label used in error messages. - - Returns: - Canonical uppercase CurrencyCode (e.g., CurrencyCode("USD")). - - Raises: - TypeError: If value is not a str instance. - ValueError: If value (after stripping and uppercasing) is not a known - ISO 4217 currency code. - BabelImportError: If Babel is not installed. - - Example: - >>> require_currency_code("usd", "currency") # doctest: +SKIP - 'USD' - >>> require_currency_code(" EUR ", "currency") # doctest: +SKIP - 'EUR' - >>> require_currency_code("XYZ", "currency") # doctest: +SKIP - Traceback (most recent call last): - ... - ValueError: currency must be a valid ISO 4217 currency code, got 'XYZ' - >>> require_currency_code(840, "currency") # doctest: +SKIP - Traceback (most recent call last): - ... - TypeError: currency must be str, got int - """ - if not isinstance(value, str): - msg = f"{field_name} must be str, got {type(value).__name__}" - raise TypeError(msg) - stripped = value.strip() - code = stripped.upper() - if len(stripped) != 3: - msg = f"{field_name} must be a valid ISO 4217 currency code, got {value!r}" - raise ValueError(msg) - if code not in _currency_codes_impl(normalize_locale("en")): - msg = f"{field_name} must be a valid ISO 4217 currency code, got {value!r}" - raise ValueError(msg) - return CurrencyCode(code) - - -def require_territory_code(value: object, field_name: str) -> TerritoryCode: - """Validate and normalize a boundary value to a canonical ISO 3166-1 alpha-2 code. - - Strips surrounding whitespace, rejects non-str types with TypeError, normalizes - to uppercase, and validates against Babel's CLDR territory database. Returns the - canonical uppercase TerritoryCode so callers need no post-validation normalization. - - Use at every constructor or API entry point that accepts a territory code field. - Eliminates the require_non_empty_str + upper() + is_valid_territory_code chain - that every downstream system would otherwise reimplement independently. - - Args: - value: Raw boundary value to validate. Accepts any Python object; non-str - values always raise TypeError; values that are not valid ISO 3166-1 - alpha-2 codes raise ValueError. - field_name: Human-readable field label used in error messages. - - Returns: - Canonical uppercase TerritoryCode (e.g., TerritoryCode("US")). - - Raises: - TypeError: If value is not a str instance. - ValueError: If value (after stripping and uppercasing) is not a known - ISO 3166-1 alpha-2 territory code. - BabelImportError: If Babel is not installed. - - Example: - >>> require_territory_code("us", "territory") # doctest: +SKIP - 'US' - >>> require_territory_code(" DE ", "territory") # doctest: +SKIP - 'DE' - >>> require_territory_code("XX", "territory") # doctest: +SKIP - Traceback (most recent call last): - ... - ValueError: territory must be a valid ISO 3166-1 alpha-2 territory code, got 'XX' - >>> require_territory_code(840, "territory") # doctest: +SKIP - Traceback (most recent call last): - ... - TypeError: territory must be str, got int - """ - if not isinstance(value, str): - msg = f"{field_name} must be str, got {type(value).__name__}" - raise TypeError(msg) - stripped = value.strip() - code = stripped.upper() - if len(stripped) != 2: - msg = ( - f"{field_name} must be a valid ISO 3166-1 alpha-2 territory code, got {value!r}" - ) - raise ValueError(msg) - if code not in _territory_codes_impl(normalize_locale("en")): - msg = ( - f"{field_name} must be a valid ISO 3166-1 alpha-2 territory code, got {value!r}" - ) - raise ValueError(msg) - return TerritoryCode(code) - - -def clear_iso_cache() -> None: - """Clear all ISO introspection caches. - - Call this if you need to free memory or after locale configuration changes. - Thread-safe. - """ - _get_babel_currencies.cache_clear() - _get_territory_impl.cache_clear() - _get_currency_impl.cache_clear() - _list_territories_impl.cache_clear() - _list_currencies_impl.cache_clear() - _get_territory_currencies_impl.cache_clear() - _territory_codes_impl.cache_clear() - _currency_codes_impl.cache_clear() diff --git a/src/ftllexengine/introspection/iso_cache.py b/src/ftllexengine/introspection/iso_cache.py new file mode 100644 index 00000000..d31cf25d --- /dev/null +++ b/src/ftllexengine/introspection/iso_cache.py @@ -0,0 +1,28 @@ +"""Cache management helpers for ISO introspection.""" + +from __future__ import annotations + +from ftllexengine.introspection.iso_babel import _get_babel_currencies +from ftllexengine.introspection.iso_lookup import ( + _get_currency_impl, + _get_territory_currencies_impl, + _get_territory_impl, + _list_currencies_impl, + _list_territories_impl, +) +from ftllexengine.introspection.iso_validation import ( + _currency_codes_impl, + _territory_codes_impl, +) + + +def clear_iso_cache() -> None: + """Clear all ISO introspection caches.""" + _get_babel_currencies.cache_clear() + _get_territory_impl.cache_clear() + _get_currency_impl.cache_clear() + _list_territories_impl.cache_clear() + _list_currencies_impl.cache_clear() + _get_territory_currencies_impl.cache_clear() + _territory_codes_impl.cache_clear() + _currency_codes_impl.cache_clear() diff --git a/src/ftllexengine/introspection/iso_lookup.py b/src/ftllexengine/introspection/iso_lookup.py new file mode 100644 index 00000000..01113b62 --- /dev/null +++ b/src/ftllexengine/introspection/iso_lookup.py @@ -0,0 +1,186 @@ +"""ISO lookup and listing helpers backed by Babel CLDR data.""" + +from __future__ import annotations + +from functools import lru_cache + +from ftllexengine.constants import ( + ISO_4217_DECIMAL_DIGITS, + ISO_4217_DEFAULT_DECIMALS, + ISO_4217_VALID_CODES, + MAX_CURRENCY_CACHE_SIZE, + MAX_LOCALE_CACHE_SIZE, + MAX_TERRITORY_CACHE_SIZE, +) +from ftllexengine.core.locale_utils import normalize_locale +from ftllexengine.introspection.iso_babel import ( + _get_babel_currencies, + _get_babel_currency_name, + _get_babel_currency_symbol, + _get_babel_official_languages, + _get_babel_territories, + _get_babel_territory_currencies, +) +from ftllexengine.introspection.iso_types import ( + CurrencyCode, + CurrencyInfo, + TerritoryCode, + TerritoryInfo, +) + + +@lru_cache(maxsize=MAX_TERRITORY_CACHE_SIZE) +def _get_territory_impl( + code_upper: str, + locale_norm: str, +) -> TerritoryInfo | None: + """Internal cached implementation for get_territory.""" + territories = _get_babel_territories(locale_norm) + + if code_upper not in territories: + return None + + name = territories[code_upper] + currencies = get_territory_currencies(code_upper) + official_languages = _get_babel_official_languages(code_upper) + + return TerritoryInfo( + alpha2=TerritoryCode(code_upper), + name=name, + currencies=currencies, + official_languages=official_languages, + ) + + +def get_territory( + code: str, + locale: str = "en", +) -> TerritoryInfo | None: + """Look up ISO 3166-1 territory by alpha-2 code.""" + if len(code) != 2: + return None + return _get_territory_impl(code.upper(), normalize_locale(locale)) + + +@lru_cache(maxsize=MAX_CURRENCY_CACHE_SIZE) +def _get_currency_impl( + code_upper: str, + locale_norm: str, +) -> CurrencyInfo | None: + """Internal cached implementation for get_currency.""" + name = _get_babel_currency_name(code_upper, locale_norm) + + if name is None: + return None + + symbol = _get_babel_currency_symbol(code_upper, locale_norm) + decimal_digits = ISO_4217_DECIMAL_DIGITS.get(code_upper, ISO_4217_DEFAULT_DECIMALS) + + return CurrencyInfo( + code=CurrencyCode(code_upper), + name=name, + symbol=symbol, + decimal_digits=decimal_digits, + ) + + +def get_currency( + code: str, + locale: str = "en", +) -> CurrencyInfo | None: + """Look up ISO 4217 currency by code.""" + if len(code) != 3: + return None + return _get_currency_impl(code.upper(), normalize_locale(locale)) + + +def get_currency_decimal_digits(code: str) -> int | None: + """Return ISO 4217 standard decimal precision for a currency code.""" + if len(code) != 3: + return None + code_upper = code.upper() + if code_upper not in ISO_4217_VALID_CODES: + return None + return ISO_4217_DECIMAL_DIGITS.get(code_upper, ISO_4217_DEFAULT_DECIMALS) + + +@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) +def _list_territories_impl( + locale_norm: str, +) -> frozenset[TerritoryInfo]: + """Internal cached implementation for list_territories.""" + territories = _get_babel_territories(locale_norm) + result: set[TerritoryInfo] = set() + + for code, name in territories.items(): + if len(code) == 2 and code.isalpha() and code.isupper(): + currencies = get_territory_currencies(code) + official_languages = _get_babel_official_languages(code) + result.add( + TerritoryInfo( + alpha2=TerritoryCode(code), + name=name, + currencies=currencies, + official_languages=official_languages, + ) + ) + + return frozenset(result) + + +def list_territories( + locale: str = "en", +) -> frozenset[TerritoryInfo]: + """List all known ISO 3166-1 territories.""" + return _list_territories_impl(normalize_locale(locale)) + + +@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) +def _list_currencies_impl( + locale_norm: str, +) -> frozenset[CurrencyInfo]: + """Internal cached implementation for list_currencies.""" + currencies_en = _get_babel_currencies() + result: set[CurrencyInfo] = set() + + for code, english_name in currencies_en.items(): + if len(code) == 3 and code.isalpha() and code.isupper(): + info = _get_currency_impl(code, locale_norm) + if info is not None: + result.add(info) + else: + symbol = _get_babel_currency_symbol(code, locale_norm) + decimal_digits = ISO_4217_DECIMAL_DIGITS.get( + code, ISO_4217_DEFAULT_DECIMALS + ) + result.add( + CurrencyInfo( + code=CurrencyCode(code), + name=english_name, + symbol=symbol, + decimal_digits=decimal_digits, + ) + ) + + return frozenset(result) + + +def list_currencies( + locale: str = "en", +) -> frozenset[CurrencyInfo]: + """List all known ISO 4217 currencies.""" + return _list_currencies_impl(normalize_locale(locale)) + + +@lru_cache(maxsize=MAX_TERRITORY_CACHE_SIZE) +def _get_territory_currencies_impl(territory_upper: str) -> tuple[CurrencyCode, ...]: + """Internal cached implementation for get_territory_currencies.""" + currencies = _get_babel_territory_currencies(territory_upper) + return tuple(CurrencyCode(c) for c in currencies) + + +def get_territory_currencies(territory: str) -> tuple[CurrencyCode, ...]: + """Get all active legal tender currencies for a territory.""" + if len(territory) != 2: + return () + return _get_territory_currencies_impl(territory.upper()) diff --git a/src/ftllexengine/introspection/iso_validation.py b/src/ftllexengine/introspection/iso_validation.py new file mode 100644 index 00000000..4ea60753 --- /dev/null +++ b/src/ftllexengine/introspection/iso_validation.py @@ -0,0 +1,76 @@ +"""ISO type guards and boundary validators.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import TypeIs + +from ftllexengine.constants import MAX_LOCALE_CACHE_SIZE +from ftllexengine.core.locale_utils import normalize_locale +from ftllexengine.introspection.iso_lookup import ( + _list_currencies_impl, + _list_territories_impl, +) +from ftllexengine.introspection.iso_types import CurrencyCode, TerritoryCode + + +@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) +def _territory_codes_impl(locale_norm: str) -> frozenset[str]: + """Internal cached implementation returning all valid territory codes.""" + return frozenset(t.alpha2 for t in _list_territories_impl(locale_norm)) + + +@lru_cache(maxsize=MAX_LOCALE_CACHE_SIZE) +def _currency_codes_impl(locale_norm: str) -> frozenset[str]: + """Internal cached implementation returning all valid currency codes.""" + return frozenset(c.code for c in _list_currencies_impl(locale_norm)) + + +def is_valid_territory_code(value: str) -> TypeIs[TerritoryCode]: + """Check if string is a valid ISO 3166-1 alpha-2 code.""" + if not isinstance(value, str) or len(value) != 2: + return False + return value.upper() in _territory_codes_impl(normalize_locale("en")) + + +def is_valid_currency_code(value: str) -> TypeIs[CurrencyCode]: + """Check if string is a valid ISO 4217 currency code.""" + if not isinstance(value, str) or len(value) != 3: + return False + return value.upper() in _currency_codes_impl(normalize_locale("en")) + + +def require_currency_code(value: object, field_name: str) -> CurrencyCode: + """Validate and normalize a boundary value to a canonical ISO 4217 currency code.""" + if not isinstance(value, str): + msg = f"{field_name} must be str, got {type(value).__name__}" + raise TypeError(msg) + stripped = value.strip() + code = stripped.upper() + if len(stripped) != 3: + msg = f"{field_name} must be a valid ISO 4217 currency code, got {value!r}" + raise ValueError(msg) + if code not in _currency_codes_impl(normalize_locale("en")): + msg = f"{field_name} must be a valid ISO 4217 currency code, got {value!r}" + raise ValueError(msg) + return CurrencyCode(code) + + +def require_territory_code(value: object, field_name: str) -> TerritoryCode: + """Validate and normalize a boundary value to a canonical ISO 3166-1 alpha-2 code.""" + if not isinstance(value, str): + msg = f"{field_name} must be str, got {type(value).__name__}" + raise TypeError(msg) + stripped = value.strip() + code = stripped.upper() + if len(stripped) != 2: + msg = ( + f"{field_name} must be a valid ISO 3166-1 alpha-2 territory code, got {value!r}" + ) + raise ValueError(msg) + if code not in _territory_codes_impl(normalize_locale("en")): + msg = ( + f"{field_name} must be a valid ISO 3166-1 alpha-2 territory code, got {value!r}" + ) + raise ValueError(msg) + return TerritoryCode(code) diff --git a/src/ftllexengine/localization/__init__.py b/src/ftllexengine/localization/__init__.py index a3bc1873..a32a0f01 100644 --- a/src/ftllexengine/localization/__init__.py +++ b/src/ftllexengine/localization/__init__.py @@ -12,16 +12,27 @@ boot - LocalizationBootConfig (one-call boot-validated assembly) Babel Optionality: - loading, types: Zero external dependencies; always importable. - orchestrator, boot, CacheAuditLogEntry: Require Babel (via FluentBundle). - On parser-only installs the Babel-dependent names are absent; accessing - them raises ImportError via the root ftllexengine.__getattr__ guard. + loading, types, CacheAuditLogEntry: Zero external dependencies; always importable. + orchestrator and boot require Babel (via FluentBundle). + On parser-only installs the Babel-dependent names are absent from normal + feature probing; direct access raises a missing-symbol error with runtime + install guidance. Python 3.13+. """ # ruff: noqa: RUF022 - __all__ organized by category for readability +from typing import TYPE_CHECKING + +from ftllexengine._optional_exports import ( + LOCALIZATION_BABEL_OPTIONAL_ATTRS as _BABEL_OPTIONAL_ATTRS, +) +from ftllexengine._optional_exports import ( + load_localization_babel_optional_exports, + raise_missing_babel_symbol, +) +from ftllexengine.core.babel_compat import is_babel_available from ftllexengine.core.semantic_types import FTLSource, LocaleCode, MessageId, ResourceId from ftllexengine.enums import LoadStatus from ftllexengine.localization.loading import ( @@ -31,46 +42,50 @@ ResourceLoader, ResourceLoadResult, ) +from ftllexengine.runtime.cache import CacheAuditLogEntry -# Babel-optional: orchestrator and boot depend on FluentBundle (runtime → Babel). -# On parser-only installs these imports fail; the names are absent from this -# package's namespace. The root ftllexengine.__getattr__ provides the hint. -try: - from ftllexengine.localization.boot import ( - LocalizationBootConfig as LocalizationBootConfig, - ) +if TYPE_CHECKING: + from ftllexengine.localization.boot import LocalizationBootConfig from ftllexengine.localization.orchestrator import ( - FluentLocalization as FluentLocalization, + FluentLocalization, + LocalizationCacheStats, ) - from ftllexengine.localization.orchestrator import ( - LocalizationCacheStats as LocalizationCacheStats, - ) - from ftllexengine.runtime.cache import ( - CacheAuditLogEntry as CacheAuditLogEntry, + +_BABEL_AVAILABLE = is_babel_available() + +if _BABEL_AVAILABLE: + globals().update(load_localization_babel_optional_exports()) + + +def __getattr__(name: str) -> object: + """Raise a targeted missing-symbol error for Babel-backed localization symbols.""" + return raise_missing_babel_symbol( + module_name=__name__, + name=name, + optional_attrs=_BABEL_OPTIONAL_ATTRS, + parser_only_hint=( + "Parser-only usage still supports ResourceLoader, PathResourceLoader, " + "FallbackInfo, ResourceLoadResult, LoadSummary, and CacheAuditLogEntry." + ), ) -except ImportError: # pragma: no cover - parser-only install; Babel-dependent names unavailable - pass # pragma: no cover - parser-only install; Babel-dependent names unavailable + __all__ = [ - # Main orchestrator (Babel-optional; absent in parser-only installs) + "CacheAuditLogEntry", + "FallbackInfo", + "FTLSource", "FluentLocalization", - "LocalizationCacheStats", - # Boot configuration (Babel-optional; absent in parser-only installs) - "LocalizationBootConfig", - # Loader protocol and implementations (no Babel dependency) - "ResourceLoader", - "PathResourceLoader", - # Load tracking (no Babel dependency) "LoadStatus", "LoadSummary", - "ResourceLoadResult", - # Fallback observability (no Babel dependency) - "FallbackInfo", - # Type aliases for user code type annotations (no Babel dependency) - "FTLSource", "LocaleCode", + "LocalizationBootConfig", + "LocalizationCacheStats", "MessageId", + "PathResourceLoader", "ResourceId", - # Public cache audit-log entry type (Babel-optional; absent in parser-only installs) - "CacheAuditLogEntry", + "ResourceLoadResult", + "ResourceLoader", ] + +if not _BABEL_AVAILABLE: + __all__ = [name for name in __all__ if name not in _BABEL_OPTIONAL_ATTRS] diff --git a/src/ftllexengine/localization/boot.py b/src/ftllexengine/localization/boot.py index cc6530a4..44c4462c 100644 --- a/src/ftllexengine/localization/boot.py +++ b/src/ftllexengine/localization/boot.py @@ -9,7 +9,8 @@ cleanly and declared message schemas must match exactly before the application accepts production traffic. -Python 3.13+. Zero external dependencies. +Python 3.13+. Requires the Babel-enabled runtime because it constructs +``FluentLocalization`` / ``FluentBundle`` instances during boot. """ from __future__ import annotations diff --git a/src/ftllexengine/localization/orchestrator.py b/src/ftllexengine/localization/orchestrator.py index 98a50fa9..d5e2148e 100644 --- a/src/ftllexengine/localization/orchestrator.py +++ b/src/ftllexengine/localization/orchestrator.py @@ -5,7 +5,7 @@ formatting (FluentBundle). Key architectural decisions: -- Eager resource and bundle initialization: FTL resources AND bundles loaded at init +- Eager resource loading with demand-driven bundle materialization - Protocol-based ResourceLoader (dependency inversion) - Immutable locale chain (established at construction) - Python 3.13 features: pattern matching, TypeIs, frozen dataclasses @@ -23,10 +23,10 @@ if summary.errors > 0: raise RuntimeError(f"Failed to load {summary.errors} resources") - Bundles are created eagerly for locales that have resources loaded during - initialization. Fallback locale bundles (for locales not in the resource - loading loop) are created lazily on first access. This hybrid approach - balances comprehensive error collection with memory efficiency. + Bundles are created as soon as a locale successfully loads a resource. + Locales without any loaded resources stay unmaterialized until first + access. This preserves eager load diagnostics without allocating empty + bundles for every fallback locale up front. Python 3.13+. """ @@ -40,6 +40,7 @@ from ftllexengine.localization.orchestrator_loading import _LocalizationLoadingMixin from ftllexengine.localization.orchestrator_queries import _LocalizationQueryMixin from ftllexengine.runtime.cache import CacheStats +from ftllexengine.runtime.locale_context import LocaleContext from ftllexengine.runtime.rwlock import RWLock if TYPE_CHECKING: @@ -153,6 +154,8 @@ def __init__( Raises: ValueError: If locales is empty ValueError: If resource_ids provided but no resource_loader + ValueError: If any locale is structurally invalid or not recognized + by Babel/CLDR """ locale_list = list(locales) if not locale_list: @@ -163,10 +166,13 @@ def __init__( msg = "resource_loader required when resource_ids provided" raise ValueError(msg) - # Canonicalize all locales eagerly (fail-fast pattern). dict.fromkeys() - # removes duplicates while maintaining insertion order. + # Canonicalize locale boundaries first, then validate against Babel/CLDR + # so localization never silently formats with a fallback locale. validated_locales = [require_locale_code(locale, "locale") for locale in locale_list] - self._locales = tuple(dict.fromkeys(validated_locales)) + strict_locales = [ + LocaleContext.create_or_raise(locale).locale_code for locale in validated_locales + ] + self._locales = tuple(dict.fromkeys(strict_locales)) # Precompute primary locale once: _locales is guaranteed non-empty (checked above) # and is immutable (tuple), so this value never changes after construction. @@ -179,9 +185,9 @@ def __init__( self._on_fallback = on_fallback self._strict = strict - # Bundle storage: only contains initialized bundles (no None markers) - # Bundles are created lazily on first access via _get_or_create_bundle - # But resources are loaded eagerly at init time for fail-fast behavior + # Bundle storage: only contains initialized bundles (no None markers). + # A bundle materializes when a resource loads successfully for a locale + # or when later read paths need it. self._bundles: dict[LocaleCode, FluentBundle] = {} # Track all load results for diagnostics @@ -195,13 +201,12 @@ def __init__( # calls (readers) while serializing add_resource/add_function (writers). self._lock = RWLock() - # Resource loading is EAGER by design: - # - Fail-fast: Critical errors (parse, permission) raised at construction - # - Predictable: All resource parse errors discovered immediately - # - Trade-off: Slower initialization, but no runtime surprises - # - Tracking: All load attempts recorded in _load_results for diagnostics - # Note: Bundles are created eagerly for locales loaded here. Fallback locale - # bundles (not in this loop) are created lazily via _get_or_create_bundle. + # Resource loading is eager by design: + # - Fail-fast: critical load/parse issues surface during construction + # - Predictable: all requested resource loads are attempted immediately + # - Demand-driven bundles: locales only get a bundle after a successful + # load or on the first later access path that needs one + # - Tracking: all load attempts are recorded in _load_results if resource_loader and resource_ids: for locale in self._locales: for resource_id in self._resource_ids: diff --git a/src/ftllexengine/runtime/__init__.py b/src/ftllexengine/runtime/__init__.py index 42d0d3f8..cb4b6311 100644 --- a/src/ftllexengine/runtime/__init__.py +++ b/src/ftllexengine/runtime/__init__.py @@ -1,29 +1,62 @@ """Fluent runtime package. -Provides message resolution, built-in functions, manual FluentNumber helpers, -custom function extension points, cache audit entry aliases, and the FluentBundle API. -Depends on syntax package for parsing. +Exposes parser-safe runtime types unconditionally and gates locale-formatting +helpers plus bundle classes behind the Babel-enabled runtime. Python 3.13+. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ftllexengine._optional_exports import ( + RUNTIME_BABEL_OPTIONAL_ATTRS as _BABEL_OPTIONAL_ATTRS, +) +from ftllexengine._optional_exports import ( + load_runtime_babel_optional_exports, + raise_missing_babel_symbol, +) +from ftllexengine.core.babel_compat import is_babel_available from ftllexengine.diagnostics import ValidationResult -from .async_bundle import AsyncFluentBundle -from .bundle import FluentBundle from .cache import CacheAuditLogEntry, WriteLogEntry from .cache_config import CacheConfig from .function_bridge import FluentNumber, FunctionRegistry, fluent_function -from .functions import ( - create_default_registry, - currency_format, - datetime_format, - get_shared_registry, - number_format, -) -from .plural_rules import select_plural_category from .value_types import make_fluent_number +if TYPE_CHECKING: + from .async_bundle import AsyncFluentBundle + from .bundle import FluentBundle + from .functions import ( + create_default_registry, + currency_format, + datetime_format, + get_shared_registry, + number_format, + ) + from .plural_rules import select_plural_category + +_BABEL_AVAILABLE = is_babel_available() + +if _BABEL_AVAILABLE: + globals().update(load_runtime_babel_optional_exports()) + + +def __getattr__(name: str) -> object: + """Raise a targeted missing-symbol error for Babel-backed runtime symbols.""" + return raise_missing_babel_symbol( + module_name=__name__, + name=name, + optional_attrs=_BABEL_OPTIONAL_ATTRS, + parser_only_hint=( + "Parser-only usage keeps CacheConfig, FluentNumber, FunctionRegistry, " + "fluent_function, make_fluent_number, ValidationResult, and cache entry types " + "importable. Locale-formatting helpers require the full runtime extra." + ), + ) + + __all__ = [ "AsyncFluentBundle", "CacheAuditLogEntry", @@ -42,3 +75,6 @@ "number_format", "select_plural_category", ] + +if not _BABEL_AVAILABLE: + __all__ = [name for name in __all__ if name not in _BABEL_OPTIONAL_ATTRS] diff --git a/src/ftllexengine/runtime/bundle.py b/src/ftllexengine/runtime/bundle.py index c063dba4..fa9f0242 100644 --- a/src/ftllexengine/runtime/bundle.py +++ b/src/ftllexengine/runtime/bundle.py @@ -154,7 +154,8 @@ def __init__( corruption handling: raises CacheCorruptionError instead of silent eviction. Raises: - ValueError: If locale code is empty or has invalid format + ValueError: If locale code is empty, structurally invalid, or not + recognized by Babel/CLDR Thread Safety: FluentBundle is always thread-safe using a readers-writer lock (RWLock). @@ -182,9 +183,11 @@ def __init__( Audit-enabled cache for compliance: >>> bundle = FluentBundle("en", cache=CacheConfig(enable_audit=True)) # doctest: +SKIP """ - # Canonicalize at the boundary so every runtime-facing locale API uses - # the same LocaleCode representation. - self._locale: LocaleCode = require_locale_code(locale, "locale") + # Validate against Babel/CLDR at the public boundary so the bundle never + # advertises one locale while formatting with a different fallback locale. + canonical_locale = require_locale_code(locale, "locale") + locale_context = LocaleContext.create_or_raise(canonical_locale) + self._locale = locale_context.locale_code self._use_isolating = use_isolating self._strict = strict self._messages: dict[str, Message] = {} @@ -516,7 +519,7 @@ def get_babel_locale(self) -> str: - bundle.locale: The canonical LocaleCode stored by FluentBundle - LocaleContext.babel_locale: The underlying Babel Locale object """ - ctx = LocaleContext.create(self._locale) + ctx = LocaleContext.create_or_raise(self._locale) return str(ctx.babel_locale) def add_resource( diff --git a/src/ftllexengine/runtime/functions.py b/src/ftllexengine/runtime/functions.py index 2ff143a6..f7fa11f5 100644 --- a/src/ftllexengine/runtime/functions.py +++ b/src/ftllexengine/runtime/functions.py @@ -86,6 +86,10 @@ def number_format( Returns: FluentNumber with formatted string and computed precision for plural matching + Raises: + ValueError: If ``locale_code`` is structurally valid but not recognized + by Babel/CLDR. + Examples: >>> from decimal import Decimal # doctest: +SKIP >>> number_format(Decimal('1234.5'), "en-US") # doctest: +SKIP @@ -125,9 +129,9 @@ def number_format( get_decimal_symbol = babel_numbers.get_decimal_symbol parse_pattern = babel_numbers.parse_pattern - # Delegate to LocaleContext (immutable, thread-safe) - # create() always returns LocaleContext with en_US fallback for invalid locales - ctx = LocaleContext.create(locale_code) + # Public runtime entry points fail fast on unknown locales instead of + # silently downgrading to a different locale's formatting rules. + ctx = LocaleContext.create_or_raise(locale_code) formatted = ctx.format_number( value, minimum_fraction_digits=minimum_fraction_digits, @@ -203,6 +207,10 @@ def datetime_format( Returns: Formatted date/datetime string + Raises: + ValueError: If ``locale_code`` is structurally valid but not recognized + by Babel/CLDR. + Examples: >>> from datetime import date, datetime, UTC # doctest: +SKIP >>> dt = datetime(2025, 10, 27, tzinfo=UTC) # doctest: +SKIP @@ -233,9 +241,9 @@ def datetime_format( Matches Intl.DateTimeFormat semantics. Custom patterns follow Babel datetime pattern syntax. """ - # Delegate to LocaleContext (immutable, thread-safe) - # create() always returns LocaleContext with en_US fallback for invalid locales - ctx = LocaleContext.create(locale_code) + # Public runtime entry points fail fast on unknown locales instead of + # silently downgrading to a different locale's formatting rules. + ctx = LocaleContext.create_or_raise(locale_code) return ctx.format_datetime( value, date_style=date_style, @@ -287,6 +295,10 @@ def currency_format( Returning FluentNumber enables CURRENCY results to be used as selectors in plural/select expressions, matching NUMBER() behavior. + Raises: + ValueError: If ``locale_code`` is structurally valid but not recognized + by Babel/CLDR. + Examples: >>> from decimal import Decimal # doctest: +SKIP >>> currency_format(Decimal('123.45'), "en-US", currency="EUR") # doctest: +SKIP @@ -330,9 +342,9 @@ def currency_format( get_decimal_symbol = babel_numbers.get_decimal_symbol parse_pattern = babel_numbers.parse_pattern - # Delegate to LocaleContext (immutable, thread-safe) - # create() always returns LocaleContext with en_US fallback for invalid locales - ctx = LocaleContext.create(locale_code) + # Public runtime entry points fail fast on unknown locales instead of + # silently downgrading to a different locale's formatting rules. + ctx = LocaleContext.create_or_raise(locale_code) formatted = ctx.format_currency( value, currency=currency, diff --git a/tests/test_architecture_contract.py b/tests/test_architecture_contract.py index 702309d8..e144123c 100644 --- a/tests/test_architecture_contract.py +++ b/tests/test_architecture_contract.py @@ -37,17 +37,31 @@ VERSION_PROVENANCE_PATTERN = re.compile(r"\b(?:Added|Pre|Post|Prior to)\s+v\d+\.\d+\.\d+\b|v\d+\.\d+\.\d+\+") -CODE_MODULE_LINE_BUDGETS = { +FILE_LINE_BUDGETS = { "src/ftllexengine/runtime/bundle.py": 900, "src/ftllexengine/runtime/cache.py": 700, "src/ftllexengine/runtime/locale_context.py": 500, "src/ftllexengine/runtime/locale_formatting.py": 400, "src/ftllexengine/runtime/resolver.py": 600, - "src/ftllexengine/introspection/iso.py": 700, + "src/ftllexengine/introspection/iso.py": 200, "src/ftllexengine/localization/orchestrator.py": 400, "src/ftllexengine/parsing/currency.py": 650, "src/ftllexengine/parsing/dates.py": 350, "src/ftllexengine/syntax/serializer.py": 700, + "src/ftllexengine/diagnostics/templates.py": 800, + "src/ftllexengine/syntax/visitor.py": 750, + "src/ftllexengine/syntax/cursor.py": 700, + "tests/test_runtime_bundle_property_core.py": 800, + "tests/test_runtime_bundle_property_references.py": 900, + "tests/test_runtime_bundle_property_advanced.py": 1000, + "tests/test_runtime_bundle_property_state.py": 750, + "tests/test_syntax_serializer.py": 3100, + "tests/test_syntax_parser_property.py": 2850, + "tests/strategies/ftl.py": 2700, + "fuzz_atheris/fuzz_localization.py": 2300, + "fuzz_atheris/fuzz_runtime.py": 1500, + "scripts/fuzz_hypofuzz.sh": 1300, + "scripts/fuzz_atheris.sh": 1100, } @@ -242,10 +256,10 @@ def test_public_examples_avoid_thread_local_storage_patterns() -> None: assert offenders == [] -def test_core_runtime_modules_stay_under_line_budgets() -> None: - """Large internal modules should remain split by responsibility.""" +def test_large_repo_files_stay_under_line_budgets() -> None: + """Large source, test, fuzz, and script files should remain split by responsibility.""" offenders: list[str] = [] - for relative_path, max_lines in CODE_MODULE_LINE_BUDGETS.items(): + for relative_path, max_lines in FILE_LINE_BUDGETS.items(): path = REPO_ROOT / relative_path line_count = len(path.read_text(encoding="utf-8").splitlines()) if line_count > max_lines: diff --git a/tests/test_diagnostics_validation.py b/tests/test_diagnostics_validation.py index b1810d44..0b578630 100644 --- a/tests/test_diagnostics_validation.py +++ b/tests/test_diagnostics_validation.py @@ -16,11 +16,14 @@ from __future__ import annotations +from dataclasses import dataclass + from hypothesis import event, given from hypothesis import strategies as st from ftllexengine.diagnostics.codes import DiagnosticCode from ftllexengine.diagnostics.validation import ( + ParserAnnotation, ValidationError, ValidationResult, ValidationWarning, @@ -385,6 +388,30 @@ def test_property_from_annotations_empty_is_valid(self) -> None: assert result.is_valid assert result.error_count == 0 + def test_parser_annotation_protocol_accepts_structural_annotations(self) -> None: + """ValidationResult should accept any structural ParserAnnotation implementation.""" + + @dataclass(frozen=True, slots=True) + class CustomParserAnnotation: + code: str + message: str + arguments: tuple[tuple[str, str], ...] | None + span: object | None + + annotation = CustomParserAnnotation( + code="custom-annotation", + message="Custom parser annotation", + arguments=(("kind", "custom"),), + span=None, + ) + + typed_annotation: ParserAnnotation = annotation + result = ValidationResult.invalid(annotations=(typed_annotation,)) + + assert result.annotations == (annotation,) + assert result.annotation_count == 1 + assert not result.is_valid + # ============================================================================ # UNIT TESTS: Specific Cases diff --git a/tests/test_documentation_tooling.py b/tests/test_documentation_tooling.py index 84342e18..9cba86d0 100644 --- a/tests/test_documentation_tooling.py +++ b/tests/test_documentation_tooling.py @@ -116,6 +116,27 @@ def test_validate_docs_configuration_tracks_runnable_python_docs() -> None: assert validate_docs.validate_python_code("raise RuntimeError('boom')", REPO_ROOT) is not None +def test_run_examples_registers_contracts_for_all_shipped_examples() -> None: + """Every shipped example should have an explicit output contract.""" + run_examples = _load_script_module( + "run_examples_script", REPO_ROOT / "scripts" / "run_examples.py" + ) + + shipped_examples = { + path.name + for path in (REPO_ROOT / "examples").glob("*.py") + if path.is_file() + } + + assert set(run_examples.EXAMPLE_CONTRACTS) == shipped_examples + assert run_examples.EXAMPLE_CONTRACTS["parser_only.py"]( + "[PASS] Warning-only validation semantics verified\n" + "[PASS] Invalid syntax semantics verified\n" + "All examples completed successfully!\n" + ) is None + assert run_examples.EXAMPLE_CONTRACTS["parser_only.py"]("incomplete output") is not None + + def test_validate_version_uses_afad_frontmatter_version_contract() -> None: """validate_version should enforce the AFAD v3.5 `version:` frontmatter key.""" pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) @@ -363,6 +384,18 @@ def test_reference_signature_parameter_names_match_live_exports() -> None: assert issues == [] +def test_diagnostics_reference_documents_parser_annotation_contract() -> None: + """Diagnostics reference should document the structural parser annotation API.""" + diagnostics_doc = REPO_ROOT / "docs" / "DOC_05_Diagnostics.md" + + parser_annotation_signature = _extract_signature_block(diagnostics_doc, "ParserAnnotation") + validation_result_signature = _extract_signature_block(diagnostics_doc, "ValidationResult") + + assert parser_annotation_signature is not None + assert "class ParserAnnotation(Protocol):" in parser_annotation_signature + assert "annotations: tuple[ParserAnnotation, ...]" in (validation_result_signature or "") + + def test_sdist_includes_root_frontmatter_docs_and_readme() -> None: """Root markdown docs with frontmatter should ship in the source distribution.""" pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) diff --git a/tests/test_init_module.py b/tests/test_init_module.py index 5fe1b6ce..60d009c6 100644 --- a/tests/test_init_module.py +++ b/tests/test_init_module.py @@ -1,9 +1,10 @@ """Tests for the ftllexengine package __init__.py module. Covers the full lifecycle of the package entry point: -- Direct attribute access (Babel-optional via try/except imports) +- Direct attribute access across parser-only-safe and Babel-backed exports - Symbol identity: top-level names alias the same objects as submodule imports - Babel-optional ImportError with actionable diagnostic message (parser-only install) +- Parser-only installs keep zero-dependency runtime/localization helpers available - AttributeError for genuinely unknown attributes - ParseResult is Babel-independent (importable without Babel via diagnostics) - Fallback version when package metadata is unavailable @@ -13,12 +14,63 @@ from __future__ import annotations import sys +from collections.abc import Iterator +from contextlib import contextmanager from importlib.metadata import PackageNotFoundError +from types import ModuleType from unittest.mock import MagicMock, patch import pytest +def _snapshot_ftl_modules() -> dict[str, ModuleType]: + """Capture loaded ftllexengine modules for later restoration.""" + return { + name: module + for name, module in sys.modules.items() + if name == "ftllexengine" or name.startswith("ftllexengine.") + } + + +def _clear_ftl_modules() -> None: + """Remove loaded ftllexengine modules from sys.modules.""" + for module_name in [ + name for name in sys.modules if name == "ftllexengine" or name.startswith("ftllexengine.") + ]: + del sys.modules[module_name] + + +@contextmanager +def _fresh_ftl_import( + *, + block_babel: bool = False, + blocked_imports: frozenset[str] = frozenset(), +) -> Iterator[ModuleType]: + """Import a fresh ftllexengine module with optional Babel blocking.""" + import builtins + import importlib + + saved_modules = _snapshot_ftl_modules() + original_import = builtins.__import__ + + def mock_import(name, globs=None, locs=None, fromlist=(), level=0): + if block_babel and (name == "babel" or name.startswith("babel.")): + raise ImportError("No module named 'babel'") + if name in blocked_imports or any(name.startswith(prefix + ".") for prefix in blocked_imports): + message = f"blocked import: {name}" + raise ModuleNotFoundError(message) + return original_import(name, globs, locs, fromlist, level) + + try: + _clear_ftl_modules() + builtins.__import__ = mock_import + yield importlib.import_module("ftllexengine") + finally: + builtins.__import__ = original_import + _clear_ftl_modules() + sys.modules.update(saved_modules) + + class TestBabelOptionalSymbolAccess: """Babel-optional symbols (CacheConfig, FluentBundle, etc.) are accessible when installed.""" @@ -140,152 +192,96 @@ def test_babel_optional_attrs_contains_expected_names(self) -> None: expected = { "AsyncFluentBundle", - "CacheConfig", - "FallbackInfo", "FluentBundle", - "FluentNumber", "FluentLocalization", - "FluentValue", - "LoadSummary", "LocalizationBootConfig", "LocalizationCacheStats", - "PathResourceLoader", - "ResourceLoadResult", - "ResourceLoader", - "fluent_function", - "make_fluent_number", - "get_cldr_version", } assert expected == ftllexengine._BABEL_OPTIONAL_ATTRS # type: ignore[attr-defined] -class TestBabelImportErrorPath: - """Babel import failures produce an actionable error message. - - Simulates Babel unavailability by re-importing ftllexengine in an environment - where runtime imports fail. In a real parser-only installation the same code - path is triggered when Babel is not installed. - """ - - def test_babel_import_error_message_for_fluent_bundle(self) -> None: - """ImportError for FluentBundle provides an install-command hint.""" - saved_modules = { - name: module - for name, module in sys.modules.items() - if name == "ftllexengine" or name.startswith("ftllexengine.") - } - - try: - for module_name in list(saved_modules.keys()): - if module_name in sys.modules: - del sys.modules[module_name] - - import builtins - import importlib - - original_import = builtins.__import__ - - def mock_import(name, globs=None, locs=None, fromlist=(), level=0): - # Exempt Babel-free localization submodules (types, loading) that - # are always importable regardless of Babel availability. - is_babel_free_localization = ( - "localization.types" in name - or "localization.loading" in name - or (level > 0 and ("localization.types" in name or "localization.loading" in name)) - ) - is_runtime_import = ( - not is_babel_free_localization - and ( - name == "ftllexengine.runtime" - or (name.startswith("ftllexengine") and "runtime" in name) - or (level > 0 and "runtime" in name) - or (name.startswith("ftllexengine") and "localization" in name) - or (level > 0 and "localization" in name) - ) - ) - if is_runtime_import: - raise ImportError("No module named 'babel'") - return original_import(name, globs, locs, fromlist, level) - - builtins.__import__ = mock_import - try: - ftllexengine = importlib.import_module("ftllexengine") - - with pytest.raises( - ImportError, - match=r"FluentBundle requires the full runtime install.*pip install ftllexengine\[babel\]", - ): - _ = ftllexengine.FluentBundle - finally: - builtins.__import__ = original_import - - finally: - all_ftl_modules = [ - n for n in sys.modules if n == "ftllexengine" or n.startswith("ftllexengine.") - ] - for m in all_ftl_modules: - del sys.modules[m] - sys.modules.update(saved_modules) - - def test_babel_import_error_message_for_cache_config(self) -> None: - """ImportError for CacheConfig provides an install-command hint.""" - saved_modules = { - name: module - for name, module in sys.modules.items() - if name == "ftllexengine" or name.startswith("ftllexengine.") - } +class TestParserOnlyFacadeBehavior: + """Parser-only installs keep zero-dependency exports while gating Babel-backed facades.""" - try: - for module_name in list(saved_modules.keys()): - if module_name in sys.modules: - del sys.modules[module_name] - - import builtins - import importlib - - original_import = builtins.__import__ - - def mock_import(name, globs=None, locs=None, fromlist=(), level=0): - # Exempt Babel-free localization submodules (types, loading) that - # are always importable regardless of Babel availability. - is_babel_free_localization = ( - "localization.types" in name - or "localization.loading" in name - or (level > 0 and ("localization.types" in name or "localization.loading" in name)) - ) - is_runtime_import = ( - not is_babel_free_localization - and ( - name == "ftllexengine.runtime" - or (name.startswith("ftllexengine") and "runtime" in name) - or (level > 0 and "runtime" in name) - or (name.startswith("ftllexengine") and "localization" in name) - or (level > 0 and "localization" in name) - ) - ) - if is_runtime_import: - raise ImportError("No module named 'babel'") - return original_import(name, globs, locs, fromlist, level) - - builtins.__import__ = mock_import - try: - ftllexengine = importlib.import_module("ftllexengine") - - with pytest.raises( - ImportError, - match=r"CacheConfig requires the full runtime install.*pip install ftllexengine\[babel\]", - ): - _ = ftllexengine.CacheConfig - finally: - builtins.__import__ = original_import - - finally: - all_ftl_modules = [ - n for n in sys.modules if n == "ftllexengine" or n.startswith("ftllexengine.") - ] - for m in all_ftl_modules: - del sys.modules[m] - sys.modules.update(saved_modules) + def test_direct_optional_attribute_access_provides_install_guidance(self) -> None: + """Direct optional attribute access raises AttributeError with install guidance.""" + with ( + _fresh_ftl_import(block_babel=True) as ftllexengine, + pytest.raises( + AttributeError, + match=r"FluentBundle requires the full runtime install.*pip install ftllexengine\[babel\]", + ), + ): + _ = ftllexengine.FluentBundle + + def test_zero_dependency_root_symbols_remain_accessible_without_babel(self) -> None: + """Parser-only installs still expose zero-dependency root helpers.""" + with _fresh_ftl_import(block_babel=True) as ftllexengine: + assert "FluentBundle" not in vars(ftllexengine) + assert "FluentBundle" not in ftllexengine.__all__ + assert "FluentLocalization" not in ftllexengine.__all__ + + assert "CacheConfig" in vars(ftllexengine) + assert "FluentNumber" in vars(ftllexengine) + assert "FluentValue" in vars(ftllexengine) + assert "LoadSummary" in vars(ftllexengine) + assert "PathResourceLoader" in vars(ftllexengine) + assert "fluent_function" in vars(ftllexengine) + assert "get_cldr_version" in vars(ftllexengine) + + def test_runtime_and_localization_facades_stay_partially_available_without_babel(self) -> None: + """Parser-only installs keep zero-dependency runtime/localization names visible.""" + with _fresh_ftl_import(block_babel=True): + from ftllexengine import localization, runtime + + assert "FluentBundle" not in runtime.__all__ + assert "AsyncFluentBundle" not in runtime.__all__ + assert "number_format" not in runtime.__all__ + assert "datetime_format" not in runtime.__all__ + assert "currency_format" not in runtime.__all__ + assert "select_plural_category" not in runtime.__all__ + assert "create_default_registry" not in runtime.__all__ + assert "get_shared_registry" not in runtime.__all__ + assert "CacheConfig" in runtime.__all__ + assert runtime.CacheConfig.__name__ == "CacheConfig" + + assert "FluentLocalization" not in localization.__all__ + assert "LocalizationBootConfig" not in localization.__all__ + assert "CacheAuditLogEntry" in localization.__all__ + assert localization.PathResourceLoader.__name__ == "PathResourceLoader" + + def test_parser_only_feature_probing_treats_optional_names_as_absent(self) -> None: + """hasattr/getattr(default) treat Babel-backed names as absent in parser-only mode.""" + with _fresh_ftl_import(block_babel=True) as ftllexengine: + from ftllexengine import localization, runtime + + assert hasattr(ftllexengine, "FluentBundle") is False + assert getattr(ftllexengine, "FluentBundle", None) is None + + assert hasattr(runtime, "number_format") is False + assert getattr(runtime, "number_format", None) is None + + assert hasattr(localization, "FluentLocalization") is False + assert getattr(localization, "FluentLocalization", None) is None + + def test_parser_only_runtime_formatter_access_still_gives_install_hint(self) -> None: + """Direct runtime formatter access raises AttributeError with install guidance.""" + with _fresh_ftl_import(block_babel=True): + from ftllexengine import runtime + + with pytest.raises( + AttributeError, + match=r"number_format requires the full runtime install.*pip install ftllexengine\[babel\]", + ): + _ = runtime.number_format + + def test_internal_runtime_import_failure_is_not_masked_as_missing_babel(self) -> None: + """A broken runtime import must surface its real error instead of a Babel hint.""" + with ( + pytest.raises(ModuleNotFoundError, match=r"ftllexengine\.runtime\.bundle"), + _fresh_ftl_import(blocked_imports=frozenset({"ftllexengine.runtime.bundle"})), + ): + pass class TestUnknownAttributeError: @@ -315,6 +311,24 @@ def test_unknown_attribute_not_in_optional_attrs(self) -> None: _ = ftllexengine.UNKNOWN_CONSTANT # type: ignore[attr-defined] +class TestOptionalExportHelper: + """Direct tests for the optional-export helper branches.""" + + def test_helper_without_parser_only_hint_raises_plain_attribute_error(self) -> None: + """Optional symbols raise AttributeError outside import machinery.""" + from ftllexengine._optional_exports import raise_missing_babel_symbol + + with pytest.raises( + AttributeError, + match=r"FluentBundle requires the full runtime install.*pip install ftllexengine\[babel\]", + ): + raise_missing_babel_symbol( + module_name="ftllexengine.runtime", + name="FluentBundle", + optional_attrs=frozenset({"FluentBundle"}), + ) + + class TestDirectImportIntrospectionSymbols: """MessageVariableValidationResult and validate_message_variables imported directly.""" @@ -559,11 +573,12 @@ def test_clear_empty_frozenset_clears_nothing(self) -> None: # Should not raise; just a no-op ftllexengine.clear_module_caches(frozenset()) - def test_clear_unknown_component_is_ignored(self) -> None: - """Unknown component names in the frozenset are silently ignored.""" + def test_clear_unknown_component_raises_value_error(self) -> None: + """Unknown component names fail fast with ValueError.""" import ftllexengine - ftllexengine.clear_module_caches(frozenset({"nonexistent.component"})) + with pytest.raises(ValueError, match="Unknown cache component selector"): + ftllexengine.clear_module_caches(frozenset({"nonexistent.component"})) def test_clear_module_caches_in_all(self) -> None: """clear_module_caches is exported in ftllexengine.__all__.""" diff --git a/tests/test_introspection_iso.py b/tests/test_introspection_iso.py index afd97727..80dfdf4c 100644 --- a/tests/test_introspection_iso.py +++ b/tests/test_introspection_iso.py @@ -989,7 +989,7 @@ def mock_get_babel_currencies() -> dict[str, str]: } with patch( - "ftllexengine.introspection.iso._get_babel_currencies", + "ftllexengine.introspection.iso_lookup._get_babel_currencies", side_effect=mock_get_babel_currencies, ): result = list_currencies() diff --git a/tests/test_localization.py b/tests/test_localization.py index 56e8faf7..8ad44dc2 100644 --- a/tests/test_localization.py +++ b/tests/test_localization.py @@ -66,6 +66,11 @@ def test_invalid_locale_format_rejected_at_init(self) -> None: with pytest.raises(ValueError, match=r"Invalid locale: 'invalid locale with spaces'"): FluentLocalization(["en", "invalid locale with spaces"]) + def test_unknown_locale_rejected_at_init(self) -> None: + """Unknown but well-formed locales are rejected before localization starts.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + FluentLocalization(["en", "xx-UNKNOWN"]) + def test_locales_property_immutable(self) -> None: """Locales property returns immutable tuple.""" l10n = FluentLocalization(["en", "fr"]) diff --git a/tests/test_localization_validation.py b/tests/test_localization_validation.py index bed3a323..b7996dbf 100644 --- a/tests/test_localization_validation.py +++ b/tests/test_localization_validation.py @@ -34,28 +34,22 @@ from ftllexengine.runtime.cache_config import CacheConfig from ftllexengine.syntax.ast import Junk, Span +_KNOWN_LOCALE_CODES = ( + "en", + "de", + "fr", + "lv", + "en_US", + "de_DE", + "fr_FR", + "lv_LV", +) + @st.composite def locale_codes(draw: st.DrawFn) -> str: - """Generate valid locale codes.""" - language = draw( - st.text( - min_size=2, - max_size=2, - alphabet=st.characters(min_codepoint=97, max_codepoint=122), - ) - ) - region = draw( - st.one_of( - st.none(), - st.text( - min_size=2, - max_size=2, - alphabet=st.characters(min_codepoint=65, max_codepoint=90), - ), - ) - ) - return f"{language}-{region}" if region else language + """Generate locale codes that are both well-formed and Babel-known.""" + return draw(st.sampled_from(_KNOWN_LOCALE_CODES)) @st.composite diff --git a/tests/test_parsing_babel_compat_unavailable.py b/tests/test_parsing_babel_compat_unavailable.py index de0f3024..6ace0543 100644 --- a/tests/test_parsing_babel_compat_unavailable.py +++ b/tests/test_parsing_babel_compat_unavailable.py @@ -26,15 +26,15 @@ from ftllexengine.parsing.dates import _is_word_boundary, _strip_era # ============================================================================ -# babel_compat.py: _check_babel_available ImportError path +# babel_compat.py: _check_babel_available missing-package vs broken-install paths # ============================================================================ class TestCheckBabelAvailableImportError: - """Test _check_babel_available() ImportError path.""" + """Test _check_babel_available() import-failure classification.""" - def test_check_babel_available_handles_import_error(self) -> None: - """_check_babel_available returns False when import fails. + def test_check_babel_available_handles_missing_top_level_babel(self) -> None: + """_check_babel_available returns False only for missing top-level Babel. Resets the module-level sentinel, simulates Babel unavailability, then restores state for subsequent tests. @@ -64,8 +64,9 @@ def mock_import_babel( level: int = 0, ) -> object: if name == "babel": - msg = "Mocked: Babel not installed" - raise ImportError(msg) + err = ModuleNotFoundError("No module named 'babel'") + err.name = "babel" + raise err return original_import( name, globals_dict, locals_dict, fromlist, level, ) @@ -79,6 +80,67 @@ def mock_import_babel( sys.modules.update(saved_modules) bc._babel_available = original_sentinel + def test_check_babel_available_reraises_internal_import_failure(self) -> None: + """Internal Babel import failures must surface instead of looking missing.""" + import ftllexengine.core.babel_compat as bc + + original_sentinel = bc._babel_available + bc._babel_available = None + + try: + original_import = __import__ + + def mock_import_babel( + name: str, + globals_dict: dict[str, object] | None = None, + locals_dict: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "babel": + msg = "simulated internal babel import failure" + raise ImportError(msg) + return original_import( + name, globals_dict, locals_dict, fromlist, level, + ) + + with ( + patch("builtins.__import__", side_effect=mock_import_babel), + pytest.raises(ImportError, match="simulated internal babel import failure"), + ): + bc._check_babel_available() + finally: + bc._babel_available = original_sentinel + + def test_check_babel_available_handles_importerror_message_fallback(self) -> None: + """Message-based fallback still recognizes a mocked missing Babel import.""" + import ftllexengine.core.babel_compat as bc + + original_sentinel = bc._babel_available + bc._babel_available = None + + try: + original_import = __import__ + + def mock_import_babel( + name: str, + globals_dict: dict[str, object] | None = None, + locals_dict: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "babel": + err = ModuleNotFoundError("No module named 'babel'") + raise err + return original_import( + name, globals_dict, locals_dict, fromlist, level, + ) + + with patch("builtins.__import__", side_effect=mock_import_babel): + assert bc._check_babel_available() is False + finally: + bc._babel_available = original_sentinel + # ============================================================================ # babel_compat.py: require_babel raise path diff --git a/tests/test_parsing_babel_unavailable.py b/tests/test_parsing_babel_unavailable.py index 20979fc4..475a3237 100644 --- a/tests/test_parsing_babel_unavailable.py +++ b/tests/test_parsing_babel_unavailable.py @@ -229,57 +229,24 @@ def test_parse_fluent_number_raises_babel_import_error(self) -> None: _bc._babel_available = None -class TestResolverPluralBabelUnavailable: - """Test FluentResolver plural matching when Babel is not installed.""" +class TestBundleConstructionBabelUnavailable: + """Test bundle construction when Babel is not installed.""" - def test_plural_matching_collects_error_when_babel_unavailable(self) -> None: - """Plural matching should collect error when Babel is unavailable. - - Tests that FluentResolver collects a FluentResolutionError with - PLURAL_SUPPORT_UNAVAILABLE diagnostic code when attempting to resolve - select expressions with numeric selectors while Babel is not installed. - """ + def test_bundle_construction_fails_fast_when_babel_unavailable(self) -> None: + """FluentBundle construction raises BabelImportError without Babel.""" from ftllexengine import FluentBundle - from ftllexengine.diagnostics import DiagnosticCode - - # Clear any caches - from ftllexengine.runtime import plural_rules - - if hasattr(plural_rules.select_plural_category, "cache_clear"): - plural_rules.select_plural_category.cache_clear() - - # Create bundle with FTL containing plural select expression - ftl = """ -items = { $count -> - [one] one item - *[other] { $count } items -} -""" mock_import = _make_import_blocker("babel") - # Reset sentinel so _check_babel_available() re-evaluates under the mock + # Reset sentinel so require_babel() re-evaluates under the mock. _bc._babel_available = None try: - with patch.object(builtins, "__import__", side_effect=mock_import): - bundle = FluentBundle("en_US", strict=False) - bundle.add_resource(ftl) - - # Format with numeric argument (should trigger plural matching) - result, errors = bundle.format_pattern("items", {"count": 1}) - - # Should fall back to default variant due to Babel unavailability - # Result may contain bidi marks, so just check for key components - assert "1" in result # Default variant used - assert "items" in result - - # Should have collected error about Babel unavailability - assert len(errors) == 1 - error = errors[0] - assert hasattr(error, "diagnostic") - assert error.diagnostic is not None - assert error.diagnostic.code == DiagnosticCode.PLURAL_SUPPORT_UNAVAILABLE - assert "Babel not installed" in error.diagnostic.message - assert "ftllexengine[babel]" in error.diagnostic.message + with ( + patch.object(builtins, "__import__", side_effect=mock_import), + pytest.raises( + BabelImportError, + match=r"LocaleContext\.create_or_raise.*ftllexengine\[babel\]", + ), + ): + FluentBundle("en_US", strict=False) finally: - # Reset sentinel so subsequent tests reinitialize with Babel available _bc._babel_available = None diff --git a/tests/test_regression_depth_memory_security.py b/tests/test_regression_depth_memory_security.py index 78842ba9..682ae7be 100644 --- a/tests/test_regression_depth_memory_security.py +++ b/tests/test_regression_depth_memory_security.py @@ -147,21 +147,29 @@ def test_bundle_rejects_malicious_locale_length(self) -> None: FluentBundle(malicious_locale) def test_bundle_accepts_extended_locale_codes(self) -> None: - """FluentBundle accepts locale codes up to 1000 characters.""" - # Test boundary cases including extended BCP 47 codes - valid_locales = [ + """FluentBundle accepts only locales that are both valid and known.""" + known_locales = [ "en", "en-US", "zh-Hans-CN", - "a" * 35, # Standard BCP 47 limit - "a" * 100, # Extended locale with private-use subtags - "a" * 999, # Just under DoS limit ] - for locale in valid_locales: + for locale in known_locales: bundle = FluentBundle(locale) assert bundle.locale == normalize_locale(locale) + def test_bundle_rejects_unknown_extended_locale_codes(self) -> None: + """FluentBundle rejects long-but-unknown locale codes after length validation.""" + unknown_locales = [ + "a" * 35, + "a" * 100, + "a" * 999, + ] + + for locale in unknown_locales: + with pytest.raises(ValueError, match="Unknown locale identifier"): + FluentBundle(locale) + def test_bundle_error_message_shows_actual_length(self) -> None: """Error message for oversized locale shows actual length.""" oversized_locale = "b" * 2000 diff --git a/tests/test_runtime_bundle.py b/tests/test_runtime_bundle.py index 1621b671..71d300d6 100644 --- a/tests/test_runtime_bundle.py +++ b/tests/test_runtime_bundle.py @@ -1772,12 +1772,10 @@ def test_handles_hyphen_locale(self) -> None: result = FluentBundle("en-GB").get_babel_locale() assert "en" in result - def test_invalid_locale_uses_fallback(self) -> None: - """get_babel_locale uses fallback for invalid locale.""" - bundle = FluentBundle("xx-INVALID") - result = bundle.get_babel_locale() - assert isinstance(result, str) - assert "en" in result.lower() + def test_invalid_locale_is_rejected_at_construction(self) -> None: + """Unknown locales are rejected before a bundle can be created.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + FluentBundle("xx-INVALID") # ============================================================================= @@ -2212,11 +2210,18 @@ def test_invalid_format_rejected(self) -> None: ), ) ) - def test_ascii_alphanumeric_accepted(self, locale: str) -> None: - """PROPERTY: ASCII alphanumeric strings starting with a letter are valid locales.""" + def test_ascii_alphanumeric_input_is_canonicalized_or_rejected(self, locale: str) -> None: + """PROPERTY: ASCII locale-like input either canonicalizes or fails explicitly.""" event(f"locale_len={len(locale)}") - bundle = FluentBundle(locale) - assert bundle.locale == normalize_locale(locale) + try: + bundle = FluentBundle(locale) + except ValueError: + with pytest.raises(ValueError, match=r"Unknown locale identifier|Invalid locale format"): + FluentBundle(locale) + event("outcome=rejected") + else: + assert bundle.locale == normalize_locale(locale) + event("outcome=accepted") class TestBundleOverwriteWarning: diff --git a/tests/test_runtime_bundle_delegation.py b/tests/test_runtime_bundle_delegation.py index 1310122e..30459ea8 100644 --- a/tests/test_runtime_bundle_delegation.py +++ b/tests/test_runtime_bundle_delegation.py @@ -15,7 +15,6 @@ from hypothesis import strategies as st from ftllexengine.constants import MAX_LOCALE_LENGTH_HARD_LIMIT -from ftllexengine.core.locale_utils import normalize_locale from ftllexengine.diagnostics import ErrorCategory from ftllexengine.integrity import FormattingIntegrityError from ftllexengine.runtime.bundle import FluentBundle @@ -37,16 +36,15 @@ def test_locale_exceeding_hard_limit_raises_valueerror(self) -> None: with pytest.raises(ValueError, match=r"locale exceeds maximum length"): FluentBundle(malicious_locale) - def test_locale_at_hard_limit_boundary_accepted(self) -> None: - """Locale at exact MAX_LOCALE_LENGTH_HARD_LIMIT boundary is accepted.""" + def test_locale_at_hard_limit_boundary_reaches_unknown_locale_validation(self) -> None: + """Boundary-length locales pass length checks and then fail as unknown locales.""" # Create locale at exact boundary (1000 chars total) # Format: "a" + ("b" * 998) + "c" = 1000 chars boundary_locale = "a" + ("b" * (MAX_LOCALE_LENGTH_HARD_LIMIT - 2)) + "c" assert len(boundary_locale) == MAX_LOCALE_LENGTH_HARD_LIMIT - # Should succeed (at boundary, not exceeding) - bundle = FluentBundle(boundary_locale) - assert bundle.locale == normalize_locale(boundary_locale) + with pytest.raises(ValueError, match="Unknown locale identifier"): + FluentBundle(boundary_locale) def test_locale_one_over_hard_limit_rejected(self) -> None: """Locale at MAX_LOCALE_LENGTH_HARD_LIMIT + 1 is rejected.""" diff --git a/tests/test_runtime_bundle_locale_date.py b/tests/test_runtime_bundle_locale_date.py index 612de0b9..3eefdc92 100644 --- a/tests/test_runtime_bundle_locale_date.py +++ b/tests/test_runtime_bundle_locale_date.py @@ -25,6 +25,16 @@ # Valid locale alphabet for property-based tests _LOCALE_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" +_KNOWN_BUNDLE_LOCALES = ( + "en", + "en_US", + "en-US", + "de", + "de_DE", + "fr_FR", + "lv_LV", + "zh_Hans_CN", +) class TestFluentBundleLocaleValidation: @@ -65,20 +75,25 @@ def test_init_with_simple_locale_succeeds(self) -> None: bundle = FluentBundle("en") assert bundle.locale == "en" - @given( - st.from_regex(r"[a-zA-Z][a-zA-Z0-9]*(_[a-zA-Z0-9]+)?", fullmatch=True) - ) + @given(st.sampled_from(_KNOWN_BUNDLE_LOCALES)) @settings(max_examples=50) - def test_valid_locale_formats_accepted(self, locale: str) -> None: - """Valid locale formats are accepted by __init__. - - BCP 47 format: alphanumeric starting with letter, optional underscore-delimited subtag. - """ - has_subtag = "_" in locale - event(f"outcome={'subtag' if has_subtag else 'simple'}") + def test_known_locale_formats_accepted(self, locale: str) -> None: + """Known locale formats are accepted by __init__ and canonicalized.""" + match locale: + case value if "-" in value: + event("outcome=hyphenated") + case value if "_" in value: + event("outcome=underscored") + case _: + event("outcome=simple") bundle = FluentBundle(locale) assert bundle.locale == normalize_locale(locale) + def test_structurally_valid_but_unknown_locale_rejected(self) -> None: + """Syntactically valid locales must still exist in Babel's locale data.""" + with pytest.raises(ValueError, match="Unknown locale identifier 'xx_xx'"): + FluentBundle("xx_XX") + @given(st.text(min_size=1, max_size=10).filter( lambda s: s.strip() == "" or not is_structurally_valid_locale_code(s.strip()) )) diff --git a/tests/test_runtime_bundle_mutation.py b/tests/test_runtime_bundle_mutation.py index 91e41df0..5a270162 100644 --- a/tests/test_runtime_bundle_mutation.py +++ b/tests/test_runtime_bundle_mutation.py @@ -41,12 +41,12 @@ def test_bundle_with_empty_locale_raises(self): FluentBundle("") def test_bundle_with_single_char_locale(self): - """Kills: len(locale) > 1 mutations. + """Kills: single-character unknown locale acceptance mutations. - Single character locale should work. + Single-character locale reaches strict locale validation and is rejected. """ - bundle = FluentBundle("a") - assert bundle.locale == "a" + with pytest.raises(ValueError, match="Unknown locale identifier 'a'"): + FluentBundle("a") class TestAddResourceTypeValidation: diff --git a/tests/test_runtime_bundle_property.py b/tests/test_runtime_bundle_property.py deleted file mode 100644 index 230cfe3c..00000000 --- a/tests/test_runtime_bundle_property.py +++ /dev/null @@ -1,3049 +0,0 @@ -"""Hypothesis property-based tests for runtime.bundle: FluentBundle operations.""" - -from __future__ import annotations - -import contextlib -import logging -from decimal import Decimal - -import pytest -from hypothesis import HealthCheck, assume, event, given, settings -from hypothesis import strategies as st - -from ftllexengine import FluentBundle -from ftllexengine.core.locale_utils import normalize_locale -from ftllexengine.diagnostics import ErrorCategory, FrozenFluentError -from tests.strategies import ftl_simple_text - -# ============================================================================ -# HYPOTHESIS STRATEGIES -# ============================================================================ - - -# Strategy for valid FTL identifiers (using st.from_regex per hypothesis.md) -ftl_identifiers = st.from_regex(r"[a-z][a-z0-9_-]*", fullmatch=True) - - -# Strategy for FTL-safe text content (no special characters that break parsing) -ftl_safe_text = st.text( - alphabet=st.characters( - blacklist_categories=("Cc", "Cs"), # Control and surrogate - blacklist_characters="{}[]*$->\n\r", # FTL syntax characters - ), - min_size=0, - max_size=100, -).filter(lambda s: s.strip() == s and len(s.strip()) > 0 if s else True) - - -# Strategy for locale codes -locale_codes = st.sampled_from([ - "en", "en_US", "en_GB", - "lv", "lv_LV", - "de", "de_DE", - "pl", "pl_PL", - "ru", "ru_RU", - "fr", "fr_FR", -]) - -log_source_paths = st.from_regex( - r"[A-Za-z0-9_-][A-Za-z0-9_. /-]{0,31}", - fullmatch=True, -) - - -# ============================================================================ -# PROPERTY TESTS - TERM ATTRIBUTES IN CYCLE DETECTION -# ============================================================================ - - -class TestTermAttributesCycleDetection: - """Property tests for term attributes in cycle detection (line 251).""" - - def test_term_with_attributes_no_cycles(self) -> None: - """Term with attributes triggers cycle detection path (line 251).""" - bundle = FluentBundle("en") - - # Add term with multiple attributes - ftl = """ --brand = Acme Corp - .legal = Acme Corporation Ltd. - .short = Acme - .marketing = The Acme Brand - -welcome = Welcome to { -brand }! -legal = { -brand.legal } -""" - bundle.add_resource(ftl) - - # Should successfully add and format - result, errors = bundle.format_pattern("legal") - assert errors == () - assert "Acme Corporation" in result - - def test_term_attributes_with_term_references(self) -> None: - """Term attributes referencing other terms (line 251).""" - bundle = FluentBundle("en") - - # Term attributes that reference other terms - ftl = """ --company-name = Acme Corp --brand = { -company-name } - .full = { -company-name } International - .legal = { -company-name } Ltd. - -welcome = { -brand.full } -""" - bundle.add_resource(ftl) - - result, errors = bundle.format_pattern("welcome") - assert errors == () - assert "Acme" in result - - @given(attr_count=st.integers(min_value=1, max_value=5)) # Keep small bound for memory - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_term_multiple_attributes_property(self, attr_count: int) -> None: - """Property: Terms with N attributes are validated correctly.""" - bundle = FluentBundle("en") - - # Generate term with multiple attributes - attrs = "\n".join(f" .attr{i} = Value {i}" for i in range(attr_count)) - ftl = f""" --term = Base Value -{attrs} - -msg = {{ -term }} -""" - bundle.add_resource(ftl) - - # Should successfully parse and validate - result, errors = bundle.format_pattern("msg") - event(f"attr_count={attr_count}") - assert errors == () - assert "Base Value" in result - event("outcome=term_multi_attr_valid") - - -# ============================================================================ -# PROPERTY TESTS - SOURCE PATH ERROR LOGGING -# ============================================================================ - - -class TestSourcePathErrorLogging: - """Property tests for source_path in error/warning logging.""" - - def test_junk_with_source_path_logging(self, caplog: pytest.LogCaptureFixture) -> None: - """Junk entry with source_path triggers warning log (line 333).""" - bundle = FluentBundle("en") - - # Add invalid FTL that produces Junk entries - # Parser will create Junk for invalid syntax - invalid_ftl = "@@@ invalid syntax $$$ {{{ [[[" - - with caplog.at_level(logging.WARNING): - try: # noqa: SIM105 - explicit except-pass preserves state machine intent - bundle.add_resource(invalid_ftl, source_path="test_file.ftl") - except Exception: # pylint: disable=broad-exception-caught - pass - - # Check that warning was logged with source_path - # Line 333 logs: "Syntax error in %s: %s", source_path, entry.content[:100] - # Junk may or may not trigger warning depending on parser behavior - # This tests that source_path is available when needed - # Verify either warning was logged or junk was handled gracefully - assert len(caplog.records) >= 0 # Logging system functional - - def test_parse_error_with_source_path_logging(self, caplog: pytest.LogCaptureFixture) -> None: - """Parse error with source_path triggers error log (line 363).""" - bundle = FluentBundle("en") - - # Add completely malformed FTL that causes critical parse error - # Use control characters that definitely break the parser - malformed_ftl = "message = \x00\x01\x02 invalid" - - with caplog.at_level(logging.ERROR): - try: # noqa: SIM105 - explicit except-pass preserves state machine intent - bundle.add_resource(malformed_ftl, source_path="error_file.ftl") - except Exception: # pylint: disable=broad-exception-caught - pass - - # Check that error was logged with source_path - # Line 363 logs: "Failed to parse resource %s: %s", source_path, e - log_messages = [record.message for record in caplog.records if record.levelname == "ERROR"] - # If there was a critical parse error, source_path should be in logs - if log_messages: - assert any("error_file.ftl" in msg for msg in log_messages) - - @given(locale=locale_codes, filename=log_source_paths) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_source_path_appears_in_logs_property( - self, - locale: str, - filename: str, - caplog: pytest.LogCaptureFixture, - ) -> None: - """Property: source_path always appears in error/warning logs when provided.""" - bundle = FluentBundle(locale) - - invalid_ftl = "invalid syntax $$$" - - with caplog.at_level(logging.WARNING): - try: # noqa: SIM105 - explicit except-pass preserves state machine intent - bundle.add_resource(invalid_ftl, source_path=filename) - except Exception: # pylint: disable=broad-exception-caught - pass - - # source_path should appear in at least one log record - if caplog.records: - messages = [record.message for record in caplog.records] - event(f"filename_len={len(filename)}") - assert any(filename in msg for msg in messages) - event("outcome=source_path_in_logs") - - -# ============================================================================ -# PROPERTY TESTS - MESSAGE VALIDATION WARNINGS -# ============================================================================ - - -class TestMessageValidationWarnings: - """Property tests for message validation warnings.""" - - def test_message_without_value_or_attributes_warning(self) -> None: - """Message with neither value nor attributes triggers warning (line 423).""" - bundle = FluentBundle("en") - - # This is actually invalid FTL syntax - a message MUST have value or attributes - # But we can test the validation logic by using validate_resource - - # Create FTL that parser might accept but validator flags - ftl = """ -valid-message = Hello -""" - # Try to construct invalid message programmatically via validation - result = bundle.validate_resource(ftl) - - # Valid FTL should have no errors or warnings - assert result.errors == () - - @given( - msg_id=ftl_identifiers, - has_value=st.booleans(), - has_attributes=st.booleans(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_message_value_attribute_combinations_property( - self, - msg_id: str, - has_value: bool, - has_attributes: bool, - ) -> None: - """Property: Messages must have value or attributes.""" - assume(has_value or has_attributes) # Skip invalid case - - event(f"has_value={has_value}") - event(f"has_attributes={has_attributes}") - bundle = FluentBundle("en") - - # Construct valid FTL - if has_value and has_attributes: - ftl = f"{msg_id} = Value\n .attr = Attribute" - elif has_value: - ftl = f"{msg_id} = Value" - else: - # Attributes only - ftl = f"{msg_id} =\n .attr = Attribute" - - bundle.add_resource(ftl) - - # Should successfully format (with value or attribute access) - if has_value: - result, errors = bundle.format_pattern(msg_id) - - assert not errors - assert isinstance(result, str) - else: - # Attributes-only message - use format_pattern with attribute selector - result, errors = bundle.format_pattern( - msg_id, - args=None, - attribute="attr", - ) - - event(f"id_len={len(msg_id)}") - assert not errors - assert isinstance(result, str) - event("outcome=attr_only_message_valid") - - -# ============================================================================ -# PROPERTY TESTS - VALIDATION ERROR HANDLING -# ============================================================================ - - -class TestValidationErrorHandling: - """Property tests for validate_resource error handling (lines 488-493).""" - - def test_validate_resource_critical_syntax_error(self) -> None: - """Critical syntax error in validate_resource returns Junk (lines 488-493).""" - bundle = FluentBundle("en") - - # Severely malformed FTL - malformed_ftl = "this is not FTL at all $$$ [[[ {{{ \x00\x01\x02" - - # Parser uses Junk nodes for syntax errors (robustness principle) - result = bundle.validate_resource(malformed_ftl) - - # Should have errors (Junk entries) - assert len(result.errors) > 0 - - @given( - invalid_char=st.sampled_from(["\x00", "\x01", "\x02", "\x03", "\x04", "\x1f"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_validate_malformed_ftl_property(self, invalid_char: str) -> None: - """Property: Validating malformed FTL returns errors, not exceptions.""" - bundle = FluentBundle("en") - - # Construct FTL with invalid control characters - malformed_ftl = f"message = Value {invalid_char} text" - - # validate_resource should handle gracefully - result = bundle.validate_resource(malformed_ftl) - - event(f"malformed_len={len(malformed_ftl)}") - # Should return ValidationResult (not raise exception) - assert hasattr(result, "errors") - assert hasattr(result, "warnings") - event("outcome=malformed_ftl_validated") - - def test_validate_empty_resource(self) -> None: - """Validating empty resource returns no errors.""" - bundle = FluentBundle("en") - - result = bundle.validate_resource("") - - assert result.errors == () - assert result.warnings == () - - def test_validate_whitespace_only_resource(self) -> None: - """Validating whitespace-only resource handles gracefully.""" - bundle = FluentBundle("en") - - result = bundle.validate_resource(" \n\n \t\t \n ") - - # Whitespace may or may not trigger parse errors depending on parser - # What matters is that it returns a ValidationResult without crashing - assert hasattr(result, "errors") - assert hasattr(result, "warnings") - assert isinstance(result.errors, tuple) - assert isinstance(result.warnings, tuple) - - @given(valid_ftl=st.text(min_size=1)) # Remove arbitrary max - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_validate_arbitrary_text_never_crashes(self, valid_ftl: str) -> None: - """Property: validate_resource never crashes, even on arbitrary text.""" - bundle = FluentBundle("en") - - # Should always return ValidationResult, never raise - result = bundle.validate_resource(valid_ftl) - - event(f"text_len={len(valid_ftl)}") - assert hasattr(result, "errors") - assert hasattr(result, "warnings") - assert isinstance(result.errors, tuple) - assert isinstance(result.warnings, tuple) - event("outcome=validate_never_crashes") - - -# ============================================================================ -# PROPERTY TESTS - FINANCIAL USE CASES -# ============================================================================ - - -class TestFinancialBundleOperations: - """Financial-grade property tests for bundle operations.""" - - @given( - amount=st.decimals(min_value=Decimal("0.01"), allow_nan=False, allow_infinity=False), - currency=st.sampled_from(["EUR", "USD", "GBP", "JPY"]), - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_currency_formatting_never_crashes( - self, - amount: Decimal, - currency: str, - locale: str, - ) -> None: - """Property: Currency formatting never crashes for valid inputs.""" - bundle = FluentBundle(locale, use_isolating=False, strict=False) - - bundle.add_resource(f'price = {{ CURRENCY($amount, currency: "{currency}") }}') - - result, _errors = bundle.format_pattern("price", {"amount": amount}) - - event(f"currency={currency}") - # Should always return string, even if there are errors - assert isinstance(result, str) - event("outcome=currency_format_no_crash") - - @given( - # Remove arbitrary max - let Hypothesis explore - quantity=st.integers(min_value=0), - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_plural_quantity_formatting( - self, - quantity: int, - locale: str, - ) -> None: - """Property: Plural formatting works for all quantities.""" - bundle = FluentBundle(locale, use_isolating=False) - - bundle.add_resource(""" -items = { $count -> - [0] No items - [1] One item - *[other] { $count } items -} -""") - - result, errors = bundle.format_pattern("items", {"count": quantity}) - - event(f"quantity={quantity}") - assert isinstance(result, str) - assert errors == () - event("outcome=plural_quantity_format") - - @given( - vat_rate=st.decimals( - min_value=Decimal("0.0"), max_value=Decimal("1.0"), - allow_nan=False, allow_infinity=False, - ), - net_amount=st.decimals(min_value=Decimal("0.01"), allow_nan=False, allow_infinity=False), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_vat_calculation_formatting( - self, - vat_rate: Decimal, - net_amount: Decimal, - ) -> None: - """Property: VAT calculations format correctly.""" - bundle = FluentBundle("lv_LV", use_isolating=False, strict=False) - - bundle.add_resource("vat = VAT: { NUMBER($vat, minimumFractionDigits: 2) }") - - vat_amount = net_amount * vat_rate - - result, _errors = bundle.format_pattern("vat", {"vat": vat_amount}) - - event(f"vat_rate={vat_rate:.2f}") - assert isinstance(result, str) - assert "VAT:" in result - # Should have properly formatted number - assert len(result) > 5 - event("outcome=vat_calc_format") - - -# ============================================================================ -# PROPERTY TESTS - BUNDLE ROBUSTNESS -# ============================================================================ - - -class TestBundleRobustness: - """Property tests for bundle robustness and error recovery.""" - - @given( - msg_count=st.integers(min_value=1, max_value=50), # Keep practical bound - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_large_resource_handling(self, msg_count: int, locale: str) -> None: - """Property: Bundle handles resources with many messages.""" - bundle = FluentBundle(locale) - - # Generate large FTL resource - messages = [f"msg{i} = Message {i}" for i in range(msg_count)] - ftl = "\n".join(messages) - - bundle.add_resource(ftl) - - # Should successfully format first and last messages - result_first, errors_first = bundle.format_pattern("msg0") - assert errors_first == () - assert "Message 0" in result_first - - result_last, errors_last = bundle.format_pattern(f"msg{msg_count - 1}") - event(f"msg_count={msg_count}") - assert errors_last == () - assert f"Message {msg_count - 1}" in result_last - event("outcome=large_resource_handled") - - @given( - locale1=locale_codes, - locale2=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_multiple_bundles_isolation(self, locale1: str, locale2: str) -> None: - """Property: Multiple bundles maintain isolation.""" - bundle1 = FluentBundle(locale1) - bundle2 = FluentBundle(locale2) - - bundle1.add_resource("greeting = Hello from bundle 1") - bundle2.add_resource("greeting = Hello from bundle 2") - - result1, _ = bundle1.format_pattern("greeting") - result2, _ = bundle2.format_pattern("greeting") - - event(f"locales={locale1},{locale2}") - # Results should be different - assert "bundle 1" in result1 - assert "bundle 2" in result2 - event("outcome=multi_bundle_isolation") - - @given(text=ftl_safe_text) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_arbitrary_text_values_never_crash(self, text: str) -> None: - """Property: Bundle handles arbitrary text values safely.""" - assume(len(text) > 0) - assume(text.isprintable() or text.isspace()) - - bundle = FluentBundle("en") - - # Create message with arbitrary text - # Escape curly braces to prevent FTL syntax errors - safe_text = text.replace("{", "{{").replace("}", "}}") - ftl = f"msg = {safe_text}" - - try: - bundle.add_resource(ftl) - result, _ = bundle.format_pattern("msg") - event(f"text_len={len(text)}") - assert isinstance(result, str) - event("outcome=arbitrary_text_no_crash") - except Exception: # pylint: disable=broad-exception-caught - # Some text might be invalid FTL, that's OK - pass - - -# ============================================================================ -# PROPERTY TESTS - EDGE CASES -# ============================================================================ - - -class TestBundleEdgeCases: - """Property tests for bundle edge cases.""" - - def test_empty_bundle_operations(self) -> None: - """Empty bundle operations work correctly.""" - bundle = FluentBundle("en", strict=False) - - # Validate empty resource - result = bundle.validate_resource("") - assert result.errors == () - assert result.warnings == () - - # Format non-existent message returns fallback - result_str, errors = bundle.format_pattern("nonexistent") - assert isinstance(result_str, str) - assert len(errors) > 0 # Should have error - - @given( - locale=st.text( - alphabet=st.characters(whitelist_categories=("Ll", "Lu")), - min_size=2, - max_size=8, - ) - ) - @settings( - suppress_health_check=[ - HealthCheck.function_scoped_fixture, - HealthCheck.filter_too_much, - ] - ) - def test_arbitrary_locale_codes_accepted(self, locale: str) -> None: - """Property: Bundle accepts arbitrary locale codes.""" - assume(locale.isalpha()) - - # Should not crash, even with non-standard locale - try: - bundle = FluentBundle(locale) - event(f"locale_len={len(locale)}") - assert bundle.locale == normalize_locale(locale) - event("outcome=arbitrary_locale_accepted") - except Exception: # pylint: disable=broad-exception-caught - # Some locales might be rejected by Babel, that's OK - pass - - def test_unicode_handling_in_messages(self) -> None: - """Bundle handles Unicode correctly in messages.""" - bundle = FluentBundle("en") - - # Add message with various Unicode characters - ftl = """ -emoji = Hello 👋 World 🌍 -arabic = مرحبا -chinese = 你好 -math = √(x²+y²) -""" - bundle.add_resource(ftl) - - # All should format correctly - for msg_id in ["emoji", "arabic", "chinese", "math"]: - result, errors = bundle.format_pattern(msg_id) - assert errors == () - assert len(result) > 0 - - -# ============================================================================ -# RESOURCE MANAGEMENT -# ============================================================================ - - -class TestResourceManagement: - """Property tests for resource management operations.""" - - @given( - msg_count=st.integers(min_value=1, max_value=50), # Keep practical bound - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_add_multiple_resources(self, msg_count: int, locale: str) -> None: - """PROPERTY: Adding multiple resources accumulates messages.""" - bundle = FluentBundle(locale) - - # Add messages in separate resources - for i in range(msg_count): - bundle.add_resource(f"msg{i} = Message {i}") - - # All messages should be accessible - for i in range(msg_count): - result, errors = bundle.format_pattern(f"msg{i}") - assert errors == () - assert f"Message {i}" in result - - event(f"resource_count={msg_count}") - event("outcome=multi_resource_accumulated") - - @given( - msg_id=ftl_identifiers, - value1=ftl_safe_text, - value2=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_overlapping_messages_last_wins( - self, msg_id: str, value1: str, value2: str - ) -> None: - """PROPERTY: Later resources override earlier messages.""" - assume(value1 != value2) - assume(len(value1) > 0 and len(value2) > 0) - - bundle = FluentBundle("en") - - bundle.add_resource(f"{msg_id} = {value1}") - bundle.add_resource(f"{msg_id} = {value2}") - - result, _ = bundle.format_pattern(msg_id) - - event(f"winner_len={len(value2)}") - # Second value should win - assert value2 in result - event("outcome=overlapping_msg_last_wins") - - @given( - resource_count=st.integers(min_value=1, max_value=15), # Keep practical bound - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_empty_resources_handled(self, resource_count: int) -> None: - """PROPERTY: Empty resources don't affect bundle.""" - bundle = FluentBundle("en") - - # Add some empty resources - for _ in range(resource_count): - bundle.add_resource("") - - bundle.add_resource("msg = Hello") - - result, errors = bundle.format_pattern("msg") - event(f"empty_resource_count={resource_count}") - assert errors == () - assert "Hello" in result - event("outcome=empty_resource_handled") - - -# ============================================================================ -# MESSAGE FORMATTING -# ============================================================================ - - -class TestMessageFormatting: - """Property tests for message formatting operations.""" - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_format_value_simple_message(self, msg_id: str, text: str) -> None: - """PROPERTY: format_value returns message value.""" - assume(len(text) > 0) - event(f"msg_id_len={len(msg_id)}") - event(f"text_len={len(text)}") - - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {text}") - - result, errors = bundle.format_pattern(msg_id) - - assert errors == () - assert text in result - - @given( - msg_id=ftl_identifiers, - attr_name=ftl_identifiers, - attr_value=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_format_pattern_with_attribute( - self, msg_id: str, attr_name: str, attr_value: str - ) -> None: - """PROPERTY: format_pattern can access attributes.""" - assume(len(attr_value) > 0) - - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id} = Main value\n" - f" .{attr_name} = {attr_value}" - ) - - result, errors = bundle.format_pattern(msg_id, attribute=attr_name) - - event(f"attr_name_len={len(attr_name)}") - assert errors == () - assert attr_value in result - event("outcome=format_pattern_attr") - - @given( - msg_id=ftl_identifiers, - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_format_missing_message_returns_fallback( - self, msg_id: str, locale: str - ) -> None: - """PROPERTY: Formatting missing message returns fallback.""" - bundle = FluentBundle(locale, strict=False) - - result, errors = bundle.format_pattern(msg_id) - - event(f"missing_id_len={len(msg_id)}") - # Should have errors - assert len(errors) > 0 - # Should return fallback string - assert isinstance(result, str) - event("outcome=format_missing_msg_fallback") - - -# ============================================================================ -# VARIABLE SUBSTITUTION -# ============================================================================ - - -class TestVariableSubstitution: - """Property tests for variable substitution.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - # Remove arbitrary bounds - var_value=st.integers(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_integer_variable_substitution( - self, msg_id: str, var_name: str, var_value: int - ) -> None: - """PROPERTY: Integer variables are substituted correctly.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") - - result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) - - event(f"int_val={var_value}") - assert errors == () - assert str(var_value) in result - event("outcome=int_var_subst") - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - var_value=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_string_variable_substitution( - self, msg_id: str, var_name: str, var_value: str - ) -> None: - """PROPERTY: String variables are substituted correctly.""" - assume(len(var_value) > 0) - - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") - - result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) - - event(f"str_val_len={len(var_value)}") - assert errors == () - assert var_value in result - event("outcome=str_var_subst") - - @given( - msg_id=ftl_identifiers, - # Keep practical bound for performance - var_count=st.integers(min_value=1, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_multiple_variable_substitution( - self, msg_id: str, var_count: int - ) -> None: - """PROPERTY: Multiple variables are substituted correctly.""" - bundle = FluentBundle("en") - - # Build FTL with multiple variables - vars_ftl = " ".join([f"{{ $var{i} }}" for i in range(var_count)]) - bundle.add_resource(f"{msg_id} = {vars_ftl}") - - # Build args dict - args: dict[str, int | str | bool] = {f"var{i}": i for i in range(var_count)} - - result, errors = bundle.format_pattern(msg_id, args) - - event(f"var_count={var_count}") - assert errors == () - # All variable values should appear - for i in range(var_count): - assert str(i) in result - event("outcome=multi_var_subst") - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_missing_variable_generates_error( - self, msg_id: str, var_name: str - ) -> None: - """PROPERTY: Missing variables generate errors.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") - - result, errors = bundle.format_pattern(msg_id, {}) - - event(f"missing_var_id_len={len(var_name)}") - # Should have error for missing variable - assert len(errors) > 0 - assert isinstance(result, str) - event("outcome=missing_var_error") - - -# ============================================================================ -# FUNCTION CALLS -# ============================================================================ - - -class TestFunctionCalls: - """Property tests for built-in function calls.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - number=st.decimals( - min_value=Decimal(-1000), - max_value=Decimal(1000), - allow_nan=False, - allow_infinity=False, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_number_function_formatting( - self, msg_id: str, var_name: str, number: Decimal - ) -> None: - """PROPERTY: NUMBER function formats numbers.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {{ NUMBER(${var_name}) }}") - - result, errors = bundle.format_pattern(msg_id, {var_name: number}) - - event(f"num={number}") - assert errors == () - assert isinstance(result, str) - assert len(result) > 0 - event("outcome=num_func_format") - - @given( - msg_id=ftl_identifiers, - currency=st.sampled_from(["USD", "EUR", "GBP", "JPY"]), - amount=st.decimals( - min_value=Decimal("0.01"), - max_value=Decimal(10000), - allow_nan=False, - allow_infinity=False, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_currency_function_formatting( - self, msg_id: str, currency: str, amount: Decimal - ) -> None: - """PROPERTY: CURRENCY function formats currency values.""" - bundle = FluentBundle("en") - bundle.add_resource( - f'{msg_id} = {{ CURRENCY($amt, currency: "{currency}") }}' - ) - - result, errors = bundle.format_pattern(msg_id, {"amt": amount}) - - event(f"currency={currency}") - assert not errors - - # May have errors depending on currency support - assert isinstance(result, str) - assert len(result) > 0 - event("outcome=currency_func_format") - - -# ============================================================================ -# TERM RESOLUTION -# ============================================================================ - - -class TestTermResolution: - """Property tests for term resolution.""" - - @given( - term_id=ftl_identifiers, - term_value=ftl_safe_text, - msg_id=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_term_reference_resolution( - self, term_id: str, term_value: str, msg_id: str - ) -> None: - """PROPERTY: Terms are resolved in messages.""" - assume(len(term_value) > 0) - assume(term_id != msg_id) - - bundle = FluentBundle("en") - bundle.add_resource( - f"-{term_id} = {term_value}\n" - f"{msg_id} = {{ -{term_id} }}" - ) - - result, errors = bundle.format_pattern(msg_id) - - event(f"id_len={len(term_id)}") - assert errors == () - assert term_value in result - event("outcome=term_ref_resolution") - - @given( - term_id=ftl_identifiers, - attr_name=ftl_identifiers, - attr_value=ftl_safe_text, - msg_id=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_term_attribute_resolution( - self, term_id: str, attr_name: str, attr_value: str, msg_id: str - ) -> None: - """PROPERTY: Term attributes are resolved.""" - assume(len(attr_value) > 0) - assume(term_id != msg_id) - - bundle = FluentBundle("en") - bundle.add_resource( - f"-{term_id} = Base\n" - f" .{attr_name} = {attr_value}\n" - f"{msg_id} = {{ -{term_id}.{attr_name} }}" - ) - - result, errors = bundle.format_pattern(msg_id) - - event(f"attr_len={len(attr_value)}") - assert errors == () - assert attr_value in result - event("outcome=term_attr_resolution") - - -# ============================================================================ -# MESSAGE REFERENCES -# ============================================================================ - - -class TestMessageReferences: - """Property tests for message references.""" - - @given( - msg_id1=ftl_identifiers, - msg_id2=ftl_identifiers, - value=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_message_reference_resolution( - self, msg_id1: str, msg_id2: str, value: str - ) -> None: - """PROPERTY: Message references are resolved.""" - assume(len(value) > 0) - assume(msg_id1 != msg_id2) - - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id1} = {value}\n" - f"{msg_id2} = Ref: {{ {msg_id1} }}" - ) - - result, errors = bundle.format_pattern(msg_id2) - - event(f"val_len={len(value)}") - assert errors == () - assert value in result - event("outcome=msg_ref_resolution") - - -# ============================================================================ -# ATTRIBUTE ACCESS -# ============================================================================ - - -class TestAttributeAccess: - """Property tests for attribute access.""" - - @given( - msg_id=ftl_identifiers, - attr_count=st.integers(min_value=1, max_value=10), # Keep practical bound - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_multiple_attributes_accessible( - self, msg_id: str, attr_count: int - ) -> None: - """PROPERTY: All attributes are accessible.""" - bundle = FluentBundle("en") - - # Build message with multiple attributes - attrs = "\n".join([f" .attr{i} = Value{i}" for i in range(attr_count)]) - bundle.add_resource(f"{msg_id} = Main\n{attrs}") - - # Access each attribute - for i in range(attr_count): - result, errors = bundle.format_pattern(msg_id, attribute=f"attr{i}") - assert errors == () - assert f"Value{i}" in result - - event(f"attr_count={attr_count}") - event("outcome=multi_attr_accessible") - - -# ============================================================================ -# LOCALE HANDLING -# ============================================================================ - - -class TestLocaleHandling: - """Property tests for locale handling.""" - - @given( - locale1=locale_codes, - locale2=locale_codes, - msg_id=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_different_locales_independent( - self, locale1: str, locale2: str, msg_id: str - ) -> None: - """PROPERTY: Different locale bundles are independent.""" - assume(locale1 != locale2) - - bundle1 = FluentBundle(locale1) - bundle2 = FluentBundle(locale2) - - bundle1.add_resource(f"{msg_id} = Locale1 value") - bundle2.add_resource(f"{msg_id} = Locale2 value") - - result1, _ = bundle1.format_pattern(msg_id) - result2, _ = bundle2.format_pattern(msg_id) - - event(f"locales={locale1},{locale2}") - assert "Locale1" in result1 - assert "Locale2" in result2 - event("outcome=locale_independence") - - -# ============================================================================ -# ERROR RECOVERY -# ============================================================================ - - -class TestErrorRecovery: - """Property tests for error recovery.""" - - @given( - msg_id=ftl_identifiers, - invalid_char=st.sampled_from(["\x00", "\x01", "\x02"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_invalid_syntax_recovers_gracefully( - self, msg_id: str, invalid_char: str - ) -> None: - """PROPERTY: Invalid syntax doesn't crash bundle.""" - bundle = FluentBundle("en") - - # Add invalid FTL - with contextlib.suppress(Exception): - bundle.add_resource(f"{msg_id} = Invalid {invalid_char} text") - - # Bundle should still be usable - bundle.add_resource("valid = Works") - _result, errors = bundle.format_pattern("valid") - event(f"invalid_char={ord(invalid_char)}") - assert errors == () - event("outcome=syntax_error_recovery") - - -# ============================================================================ -# SELECT EXPRESSIONS -# ============================================================================ - - -class TestSelectExpressions: - """Property tests for select expression handling.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - count=st.integers(min_value=0, max_value=1000), # Keep practical bound - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_plural_select_expression( - self, msg_id: str, var_name: str, count: int - ) -> None: - """PROPERTY: Plural select expressions work for all counts.""" - bundle = FluentBundle("en") - bundle.add_resource( - f"""{msg_id} = {{ ${var_name} -> - [0] No items - [1] One item - *[other] Many items -}}""" - ) - - result, errors = bundle.format_pattern(msg_id, {var_name: count}) - - event(f"count={count}") - assert errors == () - assert isinstance(result, str) - event("outcome=plural_select_valid") - - @given( - msg_id=ftl_identifiers, - locale=locale_codes, - count=st.integers(min_value=0, max_value=1000), # Keep practical bound - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_locale_specific_plurals( - self, msg_id: str, locale: str, count: int - ) -> None: - """PROPERTY: Locale-specific plurals are handled.""" - bundle = FluentBundle(locale) - bundle.add_resource( - f"""{msg_id} = {{ $count -> - [0] Zero - [1] One - [2] Two - [few] Few - [many] Many - *[other] Other -}}""" - ) - - result, errors = bundle.format_pattern(msg_id, {"count": count}) - - event(f"locale={locale}") - event(f"count={count}") - assert errors == () - assert len(result) > 0 - event("outcome=locale_plurals_valid") - - -# ============================================================================ -# NUMBER FORMATTING VARIATIONS -# ============================================================================ - - -class TestNumberFormattingVariations: - """Property tests for number formatting variations.""" - - @given( - msg_id=ftl_identifiers, - number=st.decimals( - min_value=Decimal("0.01"), - max_value=Decimal(1000), - allow_nan=False, - allow_infinity=False, - ), - min_digits=st.integers(min_value=0, max_value=4), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_number_minimum_fraction_digits( - self, msg_id: str, number: Decimal, min_digits: int - ) -> None: - """PROPERTY: minimumFractionDigits option works.""" - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id} = {{ NUMBER($num, minimumFractionDigits: {min_digits}) }}" - ) - - result, errors = bundle.format_pattern(msg_id, {"num": number}) - - event(f"min_digits={min_digits}") - assert errors == () - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - number=st.integers(min_value=0, max_value=1000000), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_number_grouping(self, msg_id: str, number: int) -> None: - """PROPERTY: Number grouping works for large numbers.""" - bundle = FluentBundle("en") - bundle.add_resource( - f'{msg_id} = {{ NUMBER($num, useGrouping: "true") }}' - ) - - result, errors = bundle.format_pattern(msg_id, {"num": number}) - - event(f"number={number}") - assert errors == () - assert isinstance(result, str) - - -# ============================================================================ -# WHITESPACE HANDLING -# ============================================================================ - - -class TestWhitespaceHandling: - """Property tests for whitespace handling.""" - - @given( - msg_id=ftl_identifiers, - spaces=st.integers(min_value=0, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_leading_whitespace_in_values( - self, msg_id: str, spaces: int - ) -> None: - """PROPERTY: Leading whitespace in values is preserved.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {' ' * spaces}Value") - - result, errors = bundle.format_pattern(msg_id) - - event(f"spaces={spaces}") - assert errors == () - # Whitespace may be trimmed by parser/formatter - assert "Value" in result - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_multiline_message_formatting( - self, msg_id: str, text: str - ) -> None: - """PROPERTY: Multiline messages format correctly.""" - assume(len(text) > 0) - assume(text.strip() == text) # No leading/trailing whitespace - assume(not text.startswith((".", "-", "*", "#", "["))) # Exclude FTL syntax - assume(text not in (".", "-", "*", "#", "[", "]")) # Exclude FTL syntax - - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id} =\n" - f" Line 1\n" - f" {text}" - ) - - result, errors = bundle.format_pattern(msg_id) - - event(f"text_len={len(text)}") - assert errors == () - assert text in result - - -# ============================================================================ -# UNICODE EDGE CASES -# ============================================================================ - - -class TestUnicodeEdgeCases: - """Property tests for Unicode edge cases.""" - - @given( - msg_id=ftl_identifiers, - emoji=st.sampled_from(["😀", "👋", "🌍", "🎉", "❤️"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_emoji_in_messages(self, msg_id: str, emoji: str) -> None: - """PROPERTY: Emoji characters are handled correctly.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Hello {emoji}") - - result, errors = bundle.format_pattern(msg_id) - - event(f"emoji={emoji}") - assert errors == () - assert emoji in result - event("outcome=emoji_msg_format") - - @given( - msg_id=ftl_identifiers, - rtl_text=st.sampled_from(["مرحبا", "שלום", "مساء"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_rtl_text_handling(self, msg_id: str, rtl_text: str) -> None: - """PROPERTY: RTL text is handled correctly.""" - bundle = FluentBundle("ar") - bundle.add_resource(f"{msg_id} = {rtl_text}") - - result, errors = bundle.format_pattern(msg_id) - - event(f"rtl_text_len={len(rtl_text)}") - assert errors == () - assert rtl_text in result - - @given( - msg_id=ftl_identifiers, - char=st.characters( - min_codepoint=0x1F600, - max_codepoint=0x1F64F, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_unicode_emoji_range(self, msg_id: str, char: str) -> None: - """PROPERTY: Unicode emoji range handled.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Emoji: {char}") - - result, errors = bundle.format_pattern(msg_id) - - event(f"codepoint={ord(char):#06x}") - assert errors == () - assert char in result - - -# ============================================================================ -# PERFORMANCE PROPERTIES -# ============================================================================ - - -class TestPerformanceProperties: - """Property tests for performance characteristics.""" - - @given( - msg_count=st.integers(min_value=10, max_value=50), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_large_bundle_performance(self, msg_count: int) -> None: - """PROPERTY: Large bundles perform reasonably.""" - bundle = FluentBundle("en") - - # Add many messages - messages = [f"msg{i} = Value {i}" for i in range(msg_count)] - bundle.add_resource("\n".join(messages)) - - # Format random message should be fast - result, _ = bundle.format_pattern(f"msg{msg_count // 2}") - event(f"msg_count={msg_count}") - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - iterations=st.integers(min_value=1, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_repeated_formatting_consistent( - self, msg_id: str, iterations: int - ) -> None: - """PROPERTY: Repeated formatting gives consistent results.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Consistent value") - - # Format same message multiple times - results = [ - bundle.format_pattern(msg_id)[0] - for _ in range(iterations) - ] - - event(f"iterations={iterations}") - # All results should be identical - assert all(r == results[0] for r in results) - - -# ============================================================================ -# ERROR MESSAGE FORMATTING -# ============================================================================ - - -class TestErrorMessageFormatting: - """Property tests for error message formatting.""" - - @given( - msg_id=ftl_identifiers, - unknown_func=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_unknown_function_error( - self, msg_id: str, unknown_func: str - ) -> None: - """PROPERTY: Unknown functions generate errors.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource( - f"{msg_id} = {{ {unknown_func.upper()}($var) }}" - ) - - result, _errors = bundle.format_pattern(msg_id, {"var": 1}) - - # May have errors for unknown function - assert isinstance(result, str) - event(f"unknown_func_len={len(unknown_func)}") - event("outcome=unknown_func_handled") - - @given( - msg_id=ftl_identifiers, - unknown_term=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_unknown_term_error( - self, msg_id: str, unknown_term: str - ) -> None: - """PROPERTY: Unknown terms generate errors.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource(f"{msg_id} = {{ -{unknown_term} }}") - - result, errors = bundle.format_pattern(msg_id) - - # Should have error for unknown term - assert len(errors) > 0 - assert isinstance(result, str) - event(f"unknown_term_len={len(unknown_term)}") - event("outcome=unknown_term_handled") - - -# ============================================================================ -# ARGUMENT TYPE HANDLING -# ============================================================================ - - -class TestArgumentTypeHandling: - """Property tests for argument type handling.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - bool_value=st.booleans(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_boolean_argument_handling( - self, msg_id: str, var_name: str, bool_value: bool - ) -> None: - """PROPERTY: Boolean arguments are handled.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {{ ${var_name} }}") - - result, errors = bundle.format_pattern(msg_id, {var_name: bool_value}) - - event(f"bool_val={bool_value}") - assert errors == () - assert isinstance(result, str) - event("outcome=bool_arg_handled") - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - list_value=st.lists(st.integers(), min_size=0, max_size=5), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_list_argument_handling( - self, msg_id: str, var_name: str, list_value: list[int] - ) -> None: - """PROPERTY: List arguments are handled.""" - event(f"list_len={len(list_value)}") - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {{ ${var_name} }}") - - result, _errors = bundle.format_pattern(msg_id, {var_name: list_value}) - - # Lists may not be supported, but shouldn't crash - assert isinstance(result, str) - - -# ============================================================================ -# ATTRIBUTE EDGE CASES -# ============================================================================ - - -class TestAttributeEdgeCases: - """Property tests for attribute edge cases.""" - - @given( - msg_id=ftl_identifiers, - attr_name=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_missing_attribute_error( - self, msg_id: str, attr_name: str - ) -> None: - """PROPERTY: Missing attributes generate errors.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource(f"{msg_id} = Value") - - result, errors = bundle.format_pattern(msg_id, attribute=attr_name) - - # Should have error for missing attribute - assert len(errors) > 0 - assert isinstance(result, str) - event(f"missing_attr_len={len(attr_name)}") - event("outcome=missing_attr_handled") - - @given( - msg_id=ftl_identifiers, - attr_name=ftl_identifiers, - var_name=ftl_identifiers, - var_value=st.integers(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_attribute_with_variables( - self, msg_id: str, attr_name: str, var_name: str, var_value: int - ) -> None: - """PROPERTY: Attributes with variables work.""" - event(f"var_value={var_value}") - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id} = Main\n" - f" .{attr_name} = Value: {{ ${var_name} }}" - ) - - result, errors = bundle.format_pattern( - msg_id, - args={var_name: var_value}, - attribute=attr_name, - ) - - assert errors == () - assert str(var_value) in result - - -# ============================================================================ -# ISOLATION MODE -# ============================================================================ - - -class TestIsolationMode: - """Property tests for isolation mode behavior.""" - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - use_isolating=st.booleans(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_isolating_mode_variants( - self, msg_id: str, text: str, use_isolating: bool - ) -> None: - """PROPERTY: Isolating mode works correctly.""" - assume(len(text) > 0) - event(f"use_isolating={use_isolating}") - - bundle = FluentBundle("en", use_isolating=use_isolating) - bundle.add_resource(f"{msg_id} = {text}") - - result, errors = bundle.format_pattern(msg_id) - - assert errors == () - # Text should always be present - assert text in result or text in result.replace("\u2068", "").replace("\u2069", "") - - -# ============================================================================ -# VALIDATION PROPERTIES -# ============================================================================ - - -class TestValidationProperties: - """Property tests for validation operations.""" - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_valid_ftl_validates_cleanly( - self, msg_id: str, text: str - ) -> None: - """PROPERTY: Valid FTL validates without errors.""" - assume(len(text) > 0) - - bundle = FluentBundle("en") - result = bundle.validate_resource(f"{msg_id} = {text}") - - # Valid FTL should have no errors - assert result.errors == () - event(f"id_len={len(msg_id)}") - event("outcome=valid_ftl_validated") - - @given( - count=st.integers(min_value=1, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_multiple_messages_validation(self, count: int) -> None: - """PROPERTY: Multiple messages validate correctly.""" - bundle = FluentBundle("en") - - messages = [f"msg{i} = Value{i}" for i in range(count)] - ftl = "\n".join(messages) - - result = bundle.validate_resource(ftl) - - event(f"msg_count={count}") - # All should validate successfully - assert result.errors == () - - -# ============================================================================ -# BUNDLE STATE -# ============================================================================ - - -class TestBundleState: - """Property tests for bundle state management.""" - - @given( - msg_id=ftl_identifiers, - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_bundle_locale_immutable( - self, msg_id: str, locale: str - ) -> None: - """PROPERTY: Bundle locale doesn't change.""" - bundle = FluentBundle(locale) - bundle.add_resource(f"{msg_id} = Value") - - # Locale should remain unchanged - assert bundle.locale == normalize_locale(locale) - - event(f"locale={locale}") - # After formatting - bundle.format_pattern(msg_id) - assert bundle.locale == normalize_locale(locale) - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_bundle_messages_persistent( - self, msg_id: str, text: str - ) -> None: - """PROPERTY: Added messages persist.""" - assume(len(text) > 0) - - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {text}") - - # Format once - result1, _ = bundle.format_pattern(msg_id) - - # Format again - should still work - result2, _ = bundle.format_pattern(msg_id) - - event(f"text_len={len(text)}") - assert result1 == result2 - assert text in result1 - - -# ============================================================================ -# CIRCULAR REFERENCE DETECTION -# ============================================================================ - - -class TestCircularReferenceDetection: - """Property tests for circular reference detection.""" - - def test_direct_circular_reference(self) -> None: - """Direct circular reference is detected.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource( - """ -msg1 = { msg2 } -msg2 = { msg1 } -""" - ) - - result, errors = bundle.format_pattern("msg1") - - # Should detect cycle and return fallback - assert len(errors) > 0 - assert isinstance(result, str) - - def test_circular_term_reference(self) -> None: - """Circular term references are detected.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource( - """ --term1 = { -term2 } --term2 = { -term1 } -msg = { -term1 } -""" - ) - - result, _errors = bundle.format_pattern("msg") - - # Should detect cycle - assert isinstance(result, str) - - def test_nested_circular_reference(self) -> None: - """Nested circular references are detected.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource( - """ -msg1 = { msg2 } -msg2 = { msg3 } -msg3 = { msg1 } -""" - ) - - result, errors = bundle.format_pattern("msg1") - - # Should detect cycle - assert len(errors) > 0 - assert isinstance(result, str) - - @given( - depth=st.integers(min_value=2, max_value=5), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_reference_chain_without_cycle(self, depth: int) -> None: - """PROPERTY: Reference chains without cycles work.""" - bundle = FluentBundle("en") - - # Build chain: msg0 -> msg1 -> msg2 -> ... -> msgN -> "End" - messages = [f"msg{i} = {{ msg{i+1} }}" for i in range(depth)] - messages.append(f"msg{depth} = End") - ftl = "\n".join(messages) - - bundle.add_resource(ftl) - - result, errors = bundle.format_pattern("msg0") - - event(f"depth={depth}") - # Should resolve entire chain - assert errors == () - assert "End" in result - - def test_complex_reference_graph(self) -> None: - """PROPERTY: Complex reference graphs are handled.""" - bundle = FluentBundle("en") - - # Create diamond pattern: msg0 -> msg1 and msg2 -> msg3 - messages = [ - "msg0 = { msg1 } { msg2 }", - "msg1 = A", - "msg2 = B", - ] - ftl = "\n".join(messages) - - bundle.add_resource(ftl) - - result, errors = bundle.format_pattern("msg0") - - # Should resolve diamond - assert errors == () - assert "A" in result - assert "B" in result - - -# ============================================================================ -# COMPLEX SELECT EXPRESSION NESTING -# ============================================================================ - - -class TestComplexSelectExpressions: - """Property tests for complex select expression nesting.""" - - def test_nested_select_expressions(self) -> None: - """Nested select expressions work.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ -msg = { $outer -> - [a] { $inner -> - [1] A1 - *[other] A-other - } - *[other] { $inner -> - [1] Other-1 - *[other] Other-other - } -} -""" - ) - - result, errors = bundle.format_pattern("msg", {"outer": "a", "inner": 1}) - - assert errors == () - assert "A1" in result - - @given( - outer_val=st.sampled_from(["a", "b", "c"]), - inner_val=st.integers(min_value=0, max_value=5), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_nested_select_all_combinations( - self, outer_val: str, inner_val: int - ) -> None: - """PROPERTY: Nested selects work for all input combinations.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ -msg = { $x -> - [a] { $y -> - [0] A0 - *[other] A-other - } - [b] { $y -> - [0] B0 - *[other] B-other - } - *[other] { $y -> - [0] C0 - *[other] C-other - } -} -""" - ) - - result, errors = bundle.format_pattern("msg", {"x": outer_val, "y": inner_val}) - - event(f"outer_val={outer_val}") - event(f"inner_val={inner_val}") - assert errors == () - assert isinstance(result, str) - assert len(result) > 0 - - def test_select_with_function_calls(self) -> None: - """Select expressions with function calls work.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ -msg = { $count -> - [0] No items - [1] One item ({ NUMBER($count) }) - *[other] { NUMBER($count) } items -} -""" - ) - - result, errors = bundle.format_pattern("msg", {"count": 5}) - - assert errors == () - assert "5" in result - assert "items" in result - - @given( - count=st.integers(min_value=0, max_value=1000), # Keep practical bound - locale=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_locale_aware_plural_select( - self, count: int, locale: str - ) -> None: - """PROPERTY: Locale-aware plural selects work.""" - bundle = FluentBundle(locale) - bundle.add_resource( - """ -items = { $count -> - [0] No items - [1] One item - [2] Two items - [few] Few items - [many] Many items - *[other] { $count } items -} -""" - ) - - result, errors = bundle.format_pattern("items", {"count": count}) - - event(f"locale={locale}") - event(f"count={count}") - assert errors == () - assert isinstance(result, str) - - def test_select_with_term_references(self) -> None: - """Select expressions with term references work.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ --brand = FTLLexEngine -msg = { $premium -> - [true] Premium { -brand } - *[false] Standard { -brand } -} -""" - ) - - result, errors = bundle.format_pattern("msg", {"premium": "true"}) - - assert errors == () - assert "Premium" in result - assert "FTLLexEngine" in result - - -# ============================================================================ -# CACHE BEHAVIOR -# ============================================================================ - - -class TestCacheBehavior: - """Property tests for FormatCache behavior.""" - - @given( - msg_id=ftl_identifiers, - text=ftl_safe_text, - iterations=st.integers(min_value=2, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_repeated_format_uses_cache( - self, msg_id: str, text: str, iterations: int - ) -> None: - """PROPERTY: Repeated formatting uses cache.""" - assume(len(text) > 0) - - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {text}") - - # Format multiple times - results = [bundle.format_pattern(msg_id)[0] for _ in range(iterations)] - - event(f"iterations={iterations}") - # All results should be identical - assert all(r == results[0] for r in results) - assert all(text in r for r in results) - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - values=st.lists(st.integers(), min_size=2, max_size=5, unique=True), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_different_args_different_results( - self, msg_id: str, var_name: str, values: list[int] - ) -> None: - """PROPERTY: Different arguments produce different results.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") - - # Format with different arguments - results = [ - bundle.format_pattern(msg_id, {var_name: val})[0] - for val in values - ] - - event(f"value_count={len(values)}") - # Results should differ - unique_results = set(results) - assert len(unique_results) == len(values) - - @given( - msg_count=st.integers(min_value=5, max_value=20), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_cache_handles_many_messages(self, msg_count: int) -> None: - """PROPERTY: Cache handles many different messages.""" - bundle = FluentBundle("en") - - # Add many messages - for i in range(msg_count): - bundle.add_resource(f"msg{i} = Message {i}") - - # Format all messages - for i in range(msg_count): - result, errors = bundle.format_pattern(f"msg{i}") - assert errors == () - assert f"Message {i}" in result - - event(f"msg_count={msg_count}") - - @given( - msg_id=ftl_identifiers, - text1=ftl_safe_text, - text2=ftl_safe_text, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_cache_invalidation_on_resource_update( - self, msg_id: str, text1: str, text2: str - ) -> None: - """PROPERTY: Cache invalidates when resources change.""" - assume(len(text1) > 0 and len(text2) > 0) - assume(text1 != text2) - - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {text1}") - - # Format once - result1, _ = bundle.format_pattern(msg_id) - assert text1 in result1 - - # Update resource - bundle.add_resource(f"{msg_id} = {text2}") - - event(f"text1_len={len(text1)}") - # Format again - should get new value - result2, _ = bundle.format_pattern(msg_id) - assert text2 in result2 - - def test_cache_with_complex_messages(self) -> None: - """Cache works with complex messages.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ --brand = FTLLexEngine -msg = { $count -> - [0] No { -brand } items - [1] One { -brand } item - *[other] { NUMBER($count) } { -brand } items -} -""" - ) - - # Format multiple times with same args - result1, _ = bundle.format_pattern("msg", {"count": 5}) - result2, _ = bundle.format_pattern("msg", {"count": 5}) - result3, _ = bundle.format_pattern("msg", {"count": 5}) - - # All should be identical - assert result1 == result2 == result3 - - -# ============================================================================ -# BIDIRECTIONAL TEXT HANDLING -# ============================================================================ - - -class TestBidirectionalTextHandling: - """Property tests for bidirectional text handling.""" - - @given( - msg_id=ftl_identifiers, - rtl_text=st.sampled_from(["مرحبا", "שלום", "سلام"]), - use_isolating=st.booleans(), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_rtl_text_with_isolating_mode( - self, msg_id: str, rtl_text: str, use_isolating: bool - ) -> None: - """PROPERTY: RTL text with isolating characters.""" - bundle = FluentBundle("ar", use_isolating=use_isolating) - bundle.add_resource(f"{msg_id} = {rtl_text}") - - result, errors = bundle.format_pattern(msg_id) - - event(f"use_isolating={use_isolating}") - assert errors == () - # Text should appear (possibly with isolating chars) - assert rtl_text in result or rtl_text in result.replace("\u2068", "").replace("\u2069", "") - - def test_mixed_ltr_rtl_text(self) -> None: - """Mixed LTR and RTL text is handled.""" - bundle = FluentBundle("ar", use_isolating=True) - bundle.add_resource("msg = Hello مرحبا World") - - result, errors = bundle.format_pattern("msg") - - assert errors == () - assert "Hello" in result.replace("\u2068", "").replace("\u2069", "") - assert "مرحبا" in result.replace("\u2068", "").replace("\u2069", "") - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - rtl_value=st.sampled_from(["مرحبا", "שלום"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_rtl_variables_with_isolating( - self, msg_id: str, var_name: str, rtl_value: str - ) -> None: - """PROPERTY: RTL variables are isolated correctly.""" - bundle = FluentBundle("ar", use_isolating=True) - bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") - - result, errors = bundle.format_pattern(msg_id, {var_name: rtl_value}) - - event(f"rtl_value_len={len(rtl_value)}") - assert errors == () - # RTL value should appear - assert rtl_value in result.replace("\u2068", "").replace("\u2069", "") - - -# ============================================================================ -# ADDITIONAL ERROR RECOVERY -# ============================================================================ - - -class TestAdditionalErrorRecovery: - """Property tests for additional error recovery scenarios.""" - - @given( - depth=st.integers(min_value=1, max_value=5), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_deeply_nested_missing_references(self, depth: int) -> None: - """PROPERTY: Deeply nested missing references are handled.""" - bundle = FluentBundle("en", strict=False) - - # Create chain with missing link - messages = [f"msg{i} = {{ msg{i+1} }}" for i in range(depth)] - # Don't add the final message - it's missing - ftl = "\n".join(messages) - - bundle.add_resource(ftl) - - result, errors = bundle.format_pattern("msg0") - - event(f"depth={depth}") - # Should have errors but not crash - assert len(errors) > 0 - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - func_name=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_unknown_function_recovery( - self, msg_id: str, func_name: str - ) -> None: - """PROPERTY: Unknown functions are handled gracefully.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource(f"{msg_id} = {{ {func_name.upper()}($var) }}") - - result, _errors = bundle.format_pattern(msg_id, {"var": 123}) - - event(f"func_name_len={len(func_name)}") - # Should return fallback without crashing - assert isinstance(result, str) - - def test_malformed_select_expression_recovery(self) -> None: - """Malformed select expressions are handled.""" - bundle = FluentBundle("en") - - # Try to add malformed select (parser should handle or reject) - with contextlib.suppress(Exception): - bundle.add_resource( - """ -msg = { $var -> - [one One value - *[other] Other value -} -""" - ) - - # Bundle should still be usable - bundle.add_resource("valid = Works fine") - _result, errors = bundle.format_pattern("valid") - assert errors == () - - @given( - msg_id=ftl_identifiers, - invalid_escape=st.sampled_from([r"\x", r"\u", r"\uGGGG"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_invalid_escape_sequence_recovery( - self, msg_id: str, invalid_escape: str - ) -> None: - """PROPERTY: Invalid escape sequences are handled.""" - bundle = FluentBundle("en") - - # Try to add message with invalid escape - with contextlib.suppress(Exception): - bundle.add_resource(f'{msg_id} = "Text {invalid_escape} more"') - - event(f"escape_seq={invalid_escape}") - # Bundle should still work - assert isinstance(bundle.locale, str) - - def test_concurrent_formatting_safety(self) -> None: - """Bundle handles concurrent formatting safely.""" - bundle = FluentBundle("en") - bundle.add_resource("msg = Hello World") - - # Format same message multiple times (simulating concurrent access) - results = [bundle.format_pattern("msg")[0] for _ in range(10)] - - # All results should be identical - assert all(r == results[0] for r in results) - assert all("Hello World" in r for r in results) - - -# ============================================================================ -# MESSAGE PATTERN COMPLEXITY -# ============================================================================ - - -class TestMessagePatternComplexity: - """Property tests for complex message patterns.""" - - def test_deeply_nested_placeables(self) -> None: - """Deeply nested placeables work.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ --inner = Inner --middle = Middle { -inner } --outer = Outer { -middle } -msg = { -outer } -""" - ) - - result, errors = bundle.format_pattern("msg") - - assert errors == () - assert "Outer" in result - assert "Middle" in result - assert "Inner" in result - - @given( - msg_id=ftl_identifiers, - var_count=st.integers(min_value=3, max_value=8), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_many_placeables_in_pattern( - self, msg_id: str, var_count: int - ) -> None: - """PROPERTY: Patterns with many placeables work.""" - bundle = FluentBundle("en") - - # Build pattern with many placeables - placeables = " ".join([f"{{ $var{i} }}" for i in range(var_count)]) - bundle.add_resource(f"{msg_id} = {placeables}") - - # Provide all variables - args: dict[str, str | int | bool] = {f"var{i}": f"V{i}" for i in range(var_count)} - - result, errors = bundle.format_pattern(msg_id, args) - - event(f"var_count={var_count}") - assert errors == () - # All values should appear - for i in range(var_count): - assert f"V{i}" in result - - def test_complex_term_with_selectors(self) -> None: - """Complex terms with selectors work.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ --brand = FTL - .full = FTLLexEngine - .short = FTL - -msg = { $variant -> - [full] { -brand.full } - *[short] { -brand.short } -} -""" - ) - - result, errors = bundle.format_pattern("msg", {"variant": "full"}) - - assert errors == () - assert "FTLLexEngine" in result - - @given( - msg_id=ftl_identifiers, - text_segments=st.lists(ftl_safe_text, min_size=2, max_size=5), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_alternating_text_and_placeables( - self, msg_id: str, text_segments: list[str] - ) -> None: - """PROPERTY: Alternating text and placeables work.""" - assume(all(len(seg) > 0 for seg in text_segments)) - - bundle = FluentBundle("en") - - # Build pattern: text0 { $v0 } text1 { $v1 } ... - pattern_parts = [] - args: dict[str, str | int | bool] = {} - for i, text in enumerate(text_segments): - pattern_parts.append(text) - if i < len(text_segments) - 1: - pattern_parts.append(f"{{ $v{i} }}") - args[f"v{i}"] = f"VAR{i}" - - pattern = " ".join(pattern_parts) - bundle.add_resource(f"{msg_id} = {pattern}") - - result, errors = bundle.format_pattern(msg_id, args) - - event(f"segment_count={len(text_segments)}") - assert errors == () - # All text segments should appear - for text in text_segments: - assert text in result - - def test_message_with_all_feature_types(self) -> None: - """Message using all feature types works.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ --brand = FTLLexEngine - -msg = Welcome to { -brand }! - You have { $count -> - [0] no items - [1] one item - *[other] { NUMBER($count) } items - }. - Price: { CURRENCY($price, currency: "USD") } - - .title = { -brand } - Message System -""" - ) - - result, errors = bundle.format_pattern( - "msg", - {"count": 5, "price": Decimal("99.99")} - ) - - assert errors == () - assert "FTLLexEngine" in result - assert "5" in result or "items" in result - - -# ============================================================================ -# FUNCTION ARGUMENT EDGE CASES -# ============================================================================ - - -class TestFunctionArgumentEdgeCases: - """Property tests for function argument edge cases.""" - - @given( - msg_id=ftl_identifiers, - number=st.decimals( - min_value=Decimal("0.0"), - max_value=Decimal("1.0"), - allow_nan=False, - allow_infinity=False, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_number_function_small_values( - self, msg_id: str, number: Decimal - ) -> None: - """PROPERTY: NUMBER handles small decimal values.""" - bundle = FluentBundle("en") - bundle.add_resource( - f"{msg_id} = {{ NUMBER($num, minimumFractionDigits: 4) }}" - ) - - result, errors = bundle.format_pattern(msg_id, {"num": number}) - - event("num_magnitude=small") - assert errors == () - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - number=st.decimals( - min_value=Decimal(1000000), - max_value=Decimal(1000000000), - allow_nan=False, - allow_infinity=False, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_number_function_large_values( - self, msg_id: str, number: Decimal - ) -> None: - """PROPERTY: NUMBER handles large values.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {{ NUMBER($num) }}") - - result, errors = bundle.format_pattern(msg_id, {"num": number}) - - event("num_magnitude=large") - assert errors == () - assert isinstance(result, str) - - def test_number_function_negative_zero(self) -> None: - """NUMBER handles negative zero correctly.""" - bundle = FluentBundle("en") - bundle.add_resource("msg = { NUMBER($num) }") - - result, errors = bundle.format_pattern("msg", {"num": Decimal("-0")}) - - assert errors == () - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - amount=st.decimals( - min_value=Decimal("0.001"), - max_value=Decimal("0.01"), - allow_nan=False, - allow_infinity=False, - ), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_currency_function_tiny_amounts( - self, msg_id: str, amount: Decimal - ) -> None: - """PROPERTY: CURRENCY handles very small amounts.""" - bundle = FluentBundle("en") - bundle.add_resource( - f'{msg_id} = {{ CURRENCY($amt, currency: "USD") }}' - ) - - result, errors = bundle.format_pattern(msg_id, {"amt": amount}) - - event("amount_magnitude=tiny") - assert not errors - - # May have errors depending on currency support - assert isinstance(result, str) - - def test_function_with_missing_required_option(self) -> None: - """Function with missing required option is handled.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource("msg = { CURRENCY($amt) }") - - result, _errors = bundle.format_pattern("msg", {"amt": Decimal("99.99")}) - - # Should handle missing currency option - assert isinstance(result, str) - - -# ============================================================================ -# LOCALE FALLBACK BEHAVIOR -# ============================================================================ - - -class TestLocaleFallbackBehavior: - """Property tests for locale fallback behavior.""" - - @given( - locale=locale_codes, - msg_id=ftl_identifiers, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_bundle_respects_locale( - self, locale: str, msg_id: str - ) -> None: - """PROPERTY: Bundle respects specified locale.""" - bundle = FluentBundle(locale) - bundle.add_resource(f"{msg_id} = Value") - - assert bundle.locale == normalize_locale(locale) - - event(f"locale={locale}") - # After formatting, locale should remain - bundle.format_pattern(msg_id) - assert bundle.locale == normalize_locale(locale) - - def test_locale_specific_number_formatting(self) -> None: - """Locale-specific number formatting works.""" - bundle_en = FluentBundle("en_US") - bundle_de = FluentBundle("de_DE") - - ftl = "msg = { NUMBER($num) }" - bundle_en.add_resource(ftl) - bundle_de.add_resource(ftl) - - result_en, _ = bundle_en.format_pattern("msg", {"num": Decimal("1234.56")}) - result_de, _ = bundle_de.format_pattern("msg", {"num": Decimal("1234.56")}) - - # Both should format, potentially differently - assert isinstance(result_en, str) - assert isinstance(result_de, str) - - @given( - locale1=locale_codes, - locale2=locale_codes, - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_locale_isolation_between_bundles( - self, locale1: str, locale2: str - ) -> None: - """PROPERTY: Locales are isolated between bundles.""" - bundle1 = FluentBundle(locale1) - bundle2 = FluentBundle(locale2) - - bundle1.add_resource("msg = Bundle 1") - bundle2.add_resource("msg = Bundle 2") - - event(f"locale1={locale1}") - event(f"locale2={locale2}") - # Locales should remain distinct - assert bundle1.locale == normalize_locale(locale1) - assert bundle2.locale == normalize_locale(locale2) - - -# ============================================================================ -# RESOURCE ORDERING -# ============================================================================ - - -class TestResourceOrdering: - """Property tests for resource ordering and priority.""" - - @given( - msg_id=ftl_identifiers, - values=st.lists(ftl_safe_text, min_size=2, max_size=5, unique=True), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_last_resource_wins( - self, msg_id: str, values: list[str] - ) -> None: - """PROPERTY: Last added resource wins for same message ID.""" - assume(all(len(v) > 0 for v in values)) - - bundle = FluentBundle("en") - - # Add same message multiple times with different values - for value in values: - bundle.add_resource(f"{msg_id} = {value}") - - result, _ = bundle.format_pattern(msg_id) - - event(f"override_count={len(values)}") - # Last value should win - assert values[-1] in result - - @given( - msg_count=st.integers(min_value=2, max_value=10), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_resource_accumulation_order(self, msg_count: int) -> None: - """PROPERTY: Resources accumulate in order.""" - bundle = FluentBundle("en") - - # Add messages one by one - for i in range(msg_count): - bundle.add_resource(f"msg{i} = Value {i}") - - # All messages should be accessible - for i in range(msg_count): - result, errors = bundle.format_pattern(f"msg{i}") - assert errors == () - assert f"Value {i}" in result - - event(f"msg_count={msg_count}") - - def test_partial_override_preserves_others(self) -> None: - """Partial resource override preserves other messages.""" - bundle = FluentBundle("en") - - # Add initial messages - bundle.add_resource( - """ -msg1 = Value 1 -msg2 = Value 2 -msg3 = Value 3 -""" - ) - - # Override only msg2 - bundle.add_resource("msg2 = New Value 2") - - # msg1 and msg3 should be unchanged - result1, _ = bundle.format_pattern("msg1") - result2, _ = bundle.format_pattern("msg2") - result3, _ = bundle.format_pattern("msg3") - - assert "Value 1" in result1 - assert "New Value 2" in result2 - assert "Value 3" in result3 - - -# ============================================================================ -# ADDITIONAL ROBUSTNESS TESTS -# ============================================================================ - - -class TestAdditionalRobustness: - """Additional property tests for bundle robustness.""" - - @given( - msg_id=ftl_identifiers, - whitespace=st.sampled_from([" ", "\t", " ", "\t\t"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_various_whitespace_types( - self, msg_id: str, whitespace: str - ) -> None: - """PROPERTY: Various whitespace types are handled.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} ={whitespace}Value") - - result, errors = bundle.format_pattern(msg_id) - - event(f"whitespace_repr={whitespace!r}") - assert errors == () - assert "Value" in result - - @given( - msg_id=ftl_identifiers, - special_char=st.sampled_from(["@", "#", "%", "&"]), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_special_characters_in_text( - self, msg_id: str, special_char: str - ) -> None: - """PROPERTY: Special characters in text are preserved.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = Text {special_char} more") - - result, errors = bundle.format_pattern(msg_id) - - event(f"special_char={special_char}") - assert errors == () - assert special_char in result - - def test_empty_message_value(self) -> None: - """Empty message values are handled.""" - bundle = FluentBundle("en", strict=False) - bundle.add_resource("msg = ") - - result, _errors = bundle.format_pattern("msg") - - # Empty value should work - assert isinstance(result, str) - - @given( - msg_id=ftl_identifiers, - number=st.integers(min_value=-2147483648, max_value=2147483647), - ) - @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) - def test_integer_boundary_values( - self, msg_id: str, number: int - ) -> None: - """PROPERTY: Integer boundary values work.""" - bundle = FluentBundle("en") - bundle.add_resource(f"{msg_id} = {{ ${msg_id} }}") - - result, errors = bundle.format_pattern(msg_id, {msg_id: number}) - - event(f"number={number}") - assert errors == () - assert str(number) in result - - def test_resource_with_only_comments(self) -> None: - """Resource with only comments is handled.""" - bundle = FluentBundle("en") - bundle.add_resource( - """ -# This is a comment -## Another comment -### More comments -""" - ) - - # Should not crash - bundle.add_resource("msg = Works") - _result, errors = bundle.format_pattern("msg") - assert errors == () - - -# ============================================================================ -# ADVANCED BUNDLE PROPERTIES (from test_bundle_advanced_hypothesis.py) -# ============================================================================ - - -class TestBundleMessageRegistry: - """Properties about message registration and retrieval.""" - - @given( - locale=st.from_regex(r"[a-zA-Z][a-zA-Z0-9]*(_[a-zA-Z0-9]+)?", fullmatch=True), - msg_id=ftl_identifiers, - msg_value=ftl_simple_text(), - ) - @settings(max_examples=500) - def test_registered_message_retrievable( - self, locale: str, msg_id: str, msg_value: str - ) -> None: - """Property: Registered messages can be retrieved.""" - event(f"locale={locale}") - bundle = FluentBundle(locale) - - ftl_source = f"{msg_id} = {msg_value}" - bundle.add_resource(ftl_source) - - assert bundle.has_message(msg_id), f"Message {msg_id} not found after registration" - - result, errors = bundle.format_pattern(msg_id) - assert isinstance(result, str), "format_pattern must return string" - assert len(result) > 0, "Formatted message should not be empty" - assert errors == (), f"No errors expected for simple message, got {errors}" - - @given( - msg_id=ftl_identifiers, - ) - @settings(max_examples=300) - def test_unregistered_message_raises_error(self, msg_id: str) -> None: - """Property: Accessing unregistered message raises FrozenFluentError.""" - event(f"msg_id_len={len(msg_id)}") - bundle = FluentBundle("en_US", strict=False) - - nonexistent_id = f"never_registered_{msg_id}" - - result, errors = bundle.format_pattern(nonexistent_id) - assert len(errors) == 1, f"Expected 1 error for nonexistent message, got {len(errors)}" - assert isinstance(errors[0], FrozenFluentError), ( - f"Expected FrozenFluentError, got {type(errors[0])}" - ) - assert errors[0].category == ErrorCategory.REFERENCE - assert result == f"{{{nonexistent_id}}}", f"Expected fallback, got {result}" - - @given( - msg_id=ftl_identifiers, - value1=ftl_simple_text(), - value2=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_message_override_behavior( - self, msg_id: str, value1: str, value2: str - ) -> None: - """Property: Later messages override earlier ones with same ID.""" - values_equal = value1.strip() == value2.strip() - event(f"values_equal={values_equal}") - bundle = FluentBundle("en_US") - - bundle.add_resource(f"{msg_id} = {value1}") - bundle.add_resource(f"{msg_id} = {value2}") - - result, errors = bundle.format_pattern(msg_id) - - assert ( - value2.strip() in result or result.strip() == value2.strip() - ), "Later message should override earlier" - assert errors == (), f"No errors expected for override, got {errors}" - - -class TestBundleVariableInterpolation: - """Properties about variable interpolation in messages.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - var_value=st.one_of( - st.text(min_size=1, max_size=50), - st.integers(), - st.decimals(allow_nan=False, allow_infinity=False), - ), - ) - @settings(max_examples=500) - def test_variable_interpolation_preserves_value( - self, msg_id: str, var_name: str, var_value: str | int | Decimal - ) -> None: - """Property: Variable values appear in formatted output.""" - var_type = type(var_value).__name__ - event(f"var_type={var_type}") - bundle = FluentBundle("en_US", use_isolating=False) - - ftl_source = f"{msg_id} = Value: {{ ${var_name} }}" - bundle.add_resource(ftl_source) - - result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) - - assert str(var_value) in result, f"Variable value {var_value} not in result: {result}" - assert errors == (), f"No errors expected for variable interpolation, got {errors}" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - ) - @settings(max_examples=300) - def test_missing_variable_graceful_degradation( - self, msg_id: str, var_name: str - ) -> None: - """Property: Missing variables cause graceful degradation, not crash.""" - event(f"var_name_len={len(var_name)}") - bundle = FluentBundle("en_US", strict=False) - - ftl_source = f"{msg_id} = Value: {{ ${var_name} }}" - bundle.add_resource(ftl_source) - - result, errors = bundle.format_pattern(msg_id, {}) - - assert isinstance(result, str), "Must return string even on error" - error_count = len(errors) - event(f"error_count={error_count}") - assert error_count > 0, "Missing variable should generate error" - - @given( - msg_id=ftl_identifiers, - var_count=st.integers(min_value=1, max_value=10), - ) - @settings(max_examples=200) - def test_multiple_variable_interpolation(self, msg_id: str, var_count: int) -> None: - """Property: Messages with multiple variables interpolate all.""" - bundle = FluentBundle("en_US", use_isolating=False) - - var_names = [f"var{i}" for i in range(var_count)] - placeholders = " ".join(f"{{ ${vn} }}" for vn in var_names) - ftl_source = f"{msg_id} = {placeholders}" - bundle.add_resource(ftl_source) - - args = {vn: str(i) for i, vn in enumerate(var_names)} - result, errors = bundle.format_pattern(msg_id, args) - - event(f"var_count={var_count}") - for value in args.values(): - assert value in result, f"Variable value {value} missing from result" - assert errors == (), f"No errors expected for multiple variables, got {errors}" - - -class TestBundleLocaleHandling: - """Properties about locale-specific behavior.""" - - @given( - locale=st.sampled_from(["en_US", "lv_LV", "pl_PL", "de_DE", "fr_FR", "ru_RU"]), - msg_id=ftl_identifiers, - msg_value=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_locale_preserved_in_bundle( - self, locale: str, msg_id: str, msg_value: str - ) -> None: - """Property: Bundle canonicalizes and preserves locale configuration.""" - bundle = FluentBundle(locale) - - assert bundle.locale == normalize_locale(locale), "Bundle locale mismatch" - - ftl_source = f"{msg_id} = {msg_value}" - bundle.add_resource(ftl_source) - - event(f"locale={locale}") - result, errors = bundle.format_pattern(msg_id) - assert isinstance(result, str), "Locale should not affect basic formatting" - assert errors == (), f"No errors expected for simple message, got {errors}" - - -class TestBundleIsolatingMarks: - """Properties about Unicode bidi isolation marks.""" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - var_value=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_isolating_marks_with_use_isolating_true( - self, msg_id: str, var_name: str, var_value: str - ) -> None: - """Property: use_isolating=True adds FSI/PDI marks around interpolated values.""" - bundle = FluentBundle("en_US", use_isolating=True) - - ftl_source = f"{msg_id} = {{ ${var_name} }}" - bundle.add_resource(ftl_source) - - result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) - - event("use_isolating=True") - assert "\u2068" in result, "FSI mark missing with use_isolating=True" - assert "\u2069" in result, "PDI mark missing with use_isolating=True" - assert errors == (), f"No errors expected for isolating marks, got {errors}" - - @given( - msg_id=ftl_identifiers, - var_name=ftl_identifiers, - var_value=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_no_isolating_marks_with_use_isolating_false( - self, msg_id: str, var_name: str, var_value: str - ) -> None: - """Property: use_isolating=False omits FSI/PDI marks.""" - bundle = FluentBundle("en_US", use_isolating=False) - - ftl_source = f"{msg_id} = {{ ${var_name} }}" - bundle.add_resource(ftl_source) - - result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) - - event("use_isolating=False") - assert "\u2068" not in result, "FSI mark present with use_isolating=False" - assert "\u2069" not in result, "PDI mark present with use_isolating=False" - assert errors == (), f"No errors expected without isolating marks, got {errors}" - - -class TestBundleValidation: - """Properties about resource validation.""" - - @given( - msg_id=ftl_identifiers, - msg_value=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_valid_resource_validation(self, msg_id: str, msg_value: str) -> None: - """Property: Valid FTL passes validation.""" - bundle = FluentBundle("en_US") - - ftl_source = f"{msg_id} = {msg_value}" - result = bundle.validate_resource(ftl_source) - - event(f"id_len={len(msg_id)}") - assert result.is_valid, f"Valid FTL failed validation: {ftl_source}" - assert result.error_count == 0, "Valid FTL should have no errors" - - @given( - invalid_syntax=st.text( - alphabet=st.characters(whitelist_categories=["Cc"]), min_size=1, max_size=50 - ), - ) - @settings(max_examples=200) - def test_invalid_resource_validation(self, invalid_syntax: str) -> None: - """Property: Invalid FTL is detected by validation.""" - bundle = FluentBundle("en_US") - - result = bundle.validate_resource(invalid_syntax) - - event(f"syntax_len={len(invalid_syntax)}") - assert isinstance(result.error_count, int), "error_count must be integer" - - -class TestBundleStateConsistency: - """Properties about bundle internal state consistency.""" - - @given( - msg_count=st.integers(min_value=1, max_value=20), - ) - @settings(max_examples=200) - def test_message_count_consistency(self, msg_count: int) -> None: - """Property: get_message_ids returns all registered messages.""" - bundle = FluentBundle("en_US") - - msg_ids = [f"msg{i}" for i in range(msg_count)] - for msg_id in msg_ids: - bundle.add_resource(f"{msg_id} = value") - - retrieved_ids = bundle.get_message_ids() - - event(f"msg_count={msg_count}") - assert len(retrieved_ids) == msg_count, "Message count mismatch" - for msg_id in msg_ids: - assert msg_id in retrieved_ids, f"Message {msg_id} missing from get_message_ids()" - - @given( - msg_id=ftl_identifiers, - msg_value=ftl_simple_text(), - ) - @settings(max_examples=300) - def test_has_message_consistency_with_format( - self, msg_id: str, msg_value: str - ) -> None: - """Property: has_message returns True iff format_pattern succeeds.""" - bundle = FluentBundle("en_US") - - bundle.add_resource(f"{msg_id} = {msg_value}") - - has_msg = bundle.has_message(msg_id) - assert has_msg, f"has_message returned False for registered message {msg_id}" - - event(f"id_len={len(msg_id)}") - result, errors = bundle.format_pattern(msg_id) - assert isinstance(result, str), "format_pattern should succeed when has_message=True" - assert errors == (), f"No errors expected when has_message=True, got {errors}" - - -class TestBundleErrorHandling: - """Properties about error handling and recovery.""" - - @given( - invalid_ftl=st.text(min_size=0, max_size=100), - valid_msg=ftl_identifiers.flatmap( - lambda mid: ftl_simple_text().map(lambda val: f"{mid} = {val}") - ), - ) - @settings(max_examples=200) - def test_bundle_continues_after_parse_errors( - self, invalid_ftl: str, valid_msg: str - ) -> None: - """Property: Bundle continues accepting resources after parse errors.""" - bundle = FluentBundle("en_US") - - with contextlib.suppress(Exception): - bundle.add_resource(invalid_ftl) - - bundle.add_resource(valid_msg) - - msg_ids = bundle.get_message_ids() - event(f"msg_count_after_error={len(msg_ids)}") - assert len(msg_ids) > 0, "Bundle should accept valid resources after errors" - - @given( - msg_id=ftl_identifiers, - exception_message=ftl_simple_text(), - ) - @settings(max_examples=200) - def test_format_pattern_never_crashes_application( - self, msg_id: str, exception_message: str - ) -> None: - """Property: format_pattern never raises unexpected exceptions.""" - bundle = FluentBundle("en_US", strict=False) - - def failing_function() -> str: - raise ValueError(exception_message) - - bundle.add_function("FAIL", failing_function) - bundle.add_resource(f"{msg_id} = {{ FAIL() }}") - - result, errors = bundle.format_pattern(msg_id) - - event(f"error_count={len(errors)}") - assert isinstance( - result, str - ), "format_pattern must return string even when function raises" - assert len(errors) > 0, "Function exception should generate error" - - -class TestBundleMetamorphicProperties: - """Metamorphic properties: relations between different operations.""" - - @given( - resource_order=st.permutations(list(range(3))), - ) - @settings(max_examples=200) - def test_addition_order_independence_without_conflicts( - self, resource_order: list[int] - ) -> None: - """Property: Adding non-conflicting resources in different orders gives same result.""" - bundle1 = FluentBundle("en_US") - bundle2 = FluentBundle("en_US") - - resources = [f"m{i} = value{i}" for i in range(3)] - - for i in range(3): - bundle1.add_resource(resources[i]) - - for idx in resource_order: - bundle2.add_resource(resources[idx]) - - ids1 = sorted(bundle1.get_message_ids()) - ids2 = sorted(bundle2.get_message_ids()) - - event(f"resource_order={resource_order}") - assert ids1 == ids2, "Resource addition order should not affect final state" diff --git a/tests/test_runtime_bundle_property_advanced.py b/tests/test_runtime_bundle_property_advanced.py new file mode 100644 index 00000000..e242e5dc --- /dev/null +++ b/tests/test_runtime_bundle_property_advanced.py @@ -0,0 +1,936 @@ +"""Hypothesis property-based tests for runtime.bundle: FluentBundle operations.""" + +from __future__ import annotations + +import contextlib +from decimal import Decimal + +from hypothesis import HealthCheck, assume, event, given, settings +from hypothesis import strategies as st + +from ftllexengine import FluentBundle +from ftllexengine.core.locale_utils import normalize_locale + +# ============================================================================ +# HYPOTHESIS STRATEGIES +# ============================================================================ + + +# Strategy for valid FTL identifiers (using st.from_regex per hypothesis.md) +ftl_identifiers = st.from_regex(r"[a-z][a-z0-9_-]*", fullmatch=True) + + +# Strategy for FTL-safe text content (no special characters that break parsing) +ftl_safe_text = st.text( + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), # Control and surrogate + blacklist_characters="{}[]*$->\n\r", # FTL syntax characters + ), + min_size=0, + max_size=100, +).filter(lambda s: s.strip() == s and len(s.strip()) > 0 if s else True) + + +# Strategy for locale codes +locale_codes = st.sampled_from([ + "en", "en_US", "en_GB", + "lv", "lv_LV", + "de", "de_DE", + "pl", "pl_PL", + "ru", "ru_RU", + "fr", "fr_FR", +]) + +log_source_paths = st.from_regex( + r"[A-Za-z0-9_-][A-Za-z0-9_. /-]{0,31}", + fullmatch=True, +) + + +# ============================================================================ +# PROPERTY TESTS - TERM ATTRIBUTES IN CYCLE DETECTION +# ============================================================================ + + +class TestIsolationMode: + """Property tests for isolation mode behavior.""" + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + use_isolating=st.booleans(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_isolating_mode_variants( + self, msg_id: str, text: str, use_isolating: bool + ) -> None: + """PROPERTY: Isolating mode works correctly.""" + assume(len(text) > 0) + event(f"use_isolating={use_isolating}") + + bundle = FluentBundle("en", use_isolating=use_isolating) + bundle.add_resource(f"{msg_id} = {text}") + + result, errors = bundle.format_pattern(msg_id) + + assert errors == () + # Text should always be present + assert text in result or text in result.replace("\u2068", "").replace("\u2069", "") + + +# ============================================================================ +# VALIDATION PROPERTIES +# ============================================================================ + +class TestValidationProperties: + """Property tests for validation operations.""" + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_valid_ftl_validates_cleanly( + self, msg_id: str, text: str + ) -> None: + """PROPERTY: Valid FTL validates without errors.""" + assume(len(text) > 0) + + bundle = FluentBundle("en") + result = bundle.validate_resource(f"{msg_id} = {text}") + + # Valid FTL should have no errors + assert result.errors == () + event(f"id_len={len(msg_id)}") + event("outcome=valid_ftl_validated") + + @given( + count=st.integers(min_value=1, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_multiple_messages_validation(self, count: int) -> None: + """PROPERTY: Multiple messages validate correctly.""" + bundle = FluentBundle("en") + + messages = [f"msg{i} = Value{i}" for i in range(count)] + ftl = "\n".join(messages) + + result = bundle.validate_resource(ftl) + + event(f"msg_count={count}") + # All should validate successfully + assert result.errors == () + + +# ============================================================================ +# BUNDLE STATE +# ============================================================================ + +class TestBundleState: + """Property tests for bundle state management.""" + + @given( + msg_id=ftl_identifiers, + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_bundle_locale_immutable( + self, msg_id: str, locale: str + ) -> None: + """PROPERTY: Bundle locale doesn't change.""" + bundle = FluentBundle(locale) + bundle.add_resource(f"{msg_id} = Value") + + # Locale should remain unchanged + assert bundle.locale == normalize_locale(locale) + + event(f"locale={locale}") + # After formatting + bundle.format_pattern(msg_id) + assert bundle.locale == normalize_locale(locale) + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_bundle_messages_persistent( + self, msg_id: str, text: str + ) -> None: + """PROPERTY: Added messages persist.""" + assume(len(text) > 0) + + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {text}") + + # Format once + result1, _ = bundle.format_pattern(msg_id) + + # Format again - should still work + result2, _ = bundle.format_pattern(msg_id) + + event(f"text_len={len(text)}") + assert result1 == result2 + assert text in result1 + + +# ============================================================================ +# CIRCULAR REFERENCE DETECTION +# ============================================================================ + +class TestCircularReferenceDetection: + """Property tests for circular reference detection.""" + + def test_direct_circular_reference(self) -> None: + """Direct circular reference is detected.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource( + """ +msg1 = { msg2 } +msg2 = { msg1 } +""" + ) + + result, errors = bundle.format_pattern("msg1") + + # Should detect cycle and return fallback + assert len(errors) > 0 + assert isinstance(result, str) + + def test_circular_term_reference(self) -> None: + """Circular term references are detected.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource( + """ +-term1 = { -term2 } +-term2 = { -term1 } +msg = { -term1 } +""" + ) + + result, _errors = bundle.format_pattern("msg") + + # Should detect cycle + assert isinstance(result, str) + + def test_nested_circular_reference(self) -> None: + """Nested circular references are detected.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource( + """ +msg1 = { msg2 } +msg2 = { msg3 } +msg3 = { msg1 } +""" + ) + + result, errors = bundle.format_pattern("msg1") + + # Should detect cycle + assert len(errors) > 0 + assert isinstance(result, str) + + @given( + depth=st.integers(min_value=2, max_value=5), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_reference_chain_without_cycle(self, depth: int) -> None: + """PROPERTY: Reference chains without cycles work.""" + bundle = FluentBundle("en") + + # Build chain: msg0 -> msg1 -> msg2 -> ... -> msgN -> "End" + messages = [f"msg{i} = {{ msg{i+1} }}" for i in range(depth)] + messages.append(f"msg{depth} = End") + ftl = "\n".join(messages) + + bundle.add_resource(ftl) + + result, errors = bundle.format_pattern("msg0") + + event(f"depth={depth}") + # Should resolve entire chain + assert errors == () + assert "End" in result + + def test_complex_reference_graph(self) -> None: + """PROPERTY: Complex reference graphs are handled.""" + bundle = FluentBundle("en") + + # Create diamond pattern: msg0 -> msg1 and msg2 -> msg3 + messages = [ + "msg0 = { msg1 } { msg2 }", + "msg1 = A", + "msg2 = B", + ] + ftl = "\n".join(messages) + + bundle.add_resource(ftl) + + result, errors = bundle.format_pattern("msg0") + + # Should resolve diamond + assert errors == () + assert "A" in result + assert "B" in result + + +# ============================================================================ +# COMPLEX SELECT EXPRESSION NESTING +# ============================================================================ + +class TestComplexSelectExpressions: + """Property tests for complex select expression nesting.""" + + def test_nested_select_expressions(self) -> None: + """Nested select expressions work.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +msg = { $outer -> + [a] { $inner -> + [1] A1 + *[other] A-other + } + *[other] { $inner -> + [1] Other-1 + *[other] Other-other + } +} +""" + ) + + result, errors = bundle.format_pattern("msg", {"outer": "a", "inner": 1}) + + assert errors == () + assert "A1" in result + + @given( + outer_val=st.sampled_from(["a", "b", "c"]), + inner_val=st.integers(min_value=0, max_value=5), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_nested_select_all_combinations( + self, outer_val: str, inner_val: int + ) -> None: + """PROPERTY: Nested selects work for all input combinations.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +msg = { $x -> + [a] { $y -> + [0] A0 + *[other] A-other + } + [b] { $y -> + [0] B0 + *[other] B-other + } + *[other] { $y -> + [0] C0 + *[other] C-other + } +} +""" + ) + + result, errors = bundle.format_pattern("msg", {"x": outer_val, "y": inner_val}) + + event(f"outer_val={outer_val}") + event(f"inner_val={inner_val}") + assert errors == () + assert isinstance(result, str) + assert len(result) > 0 + + def test_select_with_function_calls(self) -> None: + """Select expressions with function calls work.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +msg = { $count -> + [0] No items + [1] One item ({ NUMBER($count) }) + *[other] { NUMBER($count) } items +} +""" + ) + + result, errors = bundle.format_pattern("msg", {"count": 5}) + + assert errors == () + assert "5" in result + assert "items" in result + + @given( + count=st.integers(min_value=0, max_value=1000), # Keep practical bound + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_locale_aware_plural_select( + self, count: int, locale: str + ) -> None: + """PROPERTY: Locale-aware plural selects work.""" + bundle = FluentBundle(locale) + bundle.add_resource( + """ +items = { $count -> + [0] No items + [1] One item + [2] Two items + [few] Few items + [many] Many items + *[other] { $count } items +} +""" + ) + + result, errors = bundle.format_pattern("items", {"count": count}) + + event(f"locale={locale}") + event(f"count={count}") + assert errors == () + assert isinstance(result, str) + + def test_select_with_term_references(self) -> None: + """Select expressions with term references work.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +-brand = FTLLexEngine +msg = { $premium -> + [true] Premium { -brand } + *[false] Standard { -brand } +} +""" + ) + + result, errors = bundle.format_pattern("msg", {"premium": "true"}) + + assert errors == () + assert "Premium" in result + assert "FTLLexEngine" in result + + +# ============================================================================ +# CACHE BEHAVIOR +# ============================================================================ + +class TestCacheBehavior: + """Property tests for FormatCache behavior.""" + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + iterations=st.integers(min_value=2, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_repeated_format_uses_cache( + self, msg_id: str, text: str, iterations: int + ) -> None: + """PROPERTY: Repeated formatting uses cache.""" + assume(len(text) > 0) + + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {text}") + + # Format multiple times + results = [bundle.format_pattern(msg_id)[0] for _ in range(iterations)] + + event(f"iterations={iterations}") + # All results should be identical + assert all(r == results[0] for r in results) + assert all(text in r for r in results) + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + values=st.lists(st.integers(), min_size=2, max_size=5, unique=True), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_different_args_different_results( + self, msg_id: str, var_name: str, values: list[int] + ) -> None: + """PROPERTY: Different arguments produce different results.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") + + # Format with different arguments + results = [ + bundle.format_pattern(msg_id, {var_name: val})[0] + for val in values + ] + + event(f"value_count={len(values)}") + # Results should differ + unique_results = set(results) + assert len(unique_results) == len(values) + + @given( + msg_count=st.integers(min_value=5, max_value=20), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_cache_handles_many_messages(self, msg_count: int) -> None: + """PROPERTY: Cache handles many different messages.""" + bundle = FluentBundle("en") + + # Add many messages + for i in range(msg_count): + bundle.add_resource(f"msg{i} = Message {i}") + + # Format all messages + for i in range(msg_count): + result, errors = bundle.format_pattern(f"msg{i}") + assert errors == () + assert f"Message {i}" in result + + event(f"msg_count={msg_count}") + + @given( + msg_id=ftl_identifiers, + text1=ftl_safe_text, + text2=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_cache_invalidation_on_resource_update( + self, msg_id: str, text1: str, text2: str + ) -> None: + """PROPERTY: Cache invalidates when resources change.""" + assume(len(text1) > 0 and len(text2) > 0) + assume(text1 != text2) + + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {text1}") + + # Format once + result1, _ = bundle.format_pattern(msg_id) + assert text1 in result1 + + # Update resource + bundle.add_resource(f"{msg_id} = {text2}") + + event(f"text1_len={len(text1)}") + # Format again - should get new value + result2, _ = bundle.format_pattern(msg_id) + assert text2 in result2 + + def test_cache_with_complex_messages(self) -> None: + """Cache works with complex messages.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +-brand = FTLLexEngine +msg = { $count -> + [0] No { -brand } items + [1] One { -brand } item + *[other] { NUMBER($count) } { -brand } items +} +""" + ) + + # Format multiple times with same args + result1, _ = bundle.format_pattern("msg", {"count": 5}) + result2, _ = bundle.format_pattern("msg", {"count": 5}) + result3, _ = bundle.format_pattern("msg", {"count": 5}) + + # All should be identical + assert result1 == result2 == result3 + + +# ============================================================================ +# BIDIRECTIONAL TEXT HANDLING +# ============================================================================ + +class TestBidirectionalTextHandling: + """Property tests for bidirectional text handling.""" + + @given( + msg_id=ftl_identifiers, + rtl_text=st.sampled_from(["مرحبا", "שלום", "سلام"]), + use_isolating=st.booleans(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_rtl_text_with_isolating_mode( + self, msg_id: str, rtl_text: str, use_isolating: bool + ) -> None: + """PROPERTY: RTL text with isolating characters.""" + bundle = FluentBundle("ar", use_isolating=use_isolating) + bundle.add_resource(f"{msg_id} = {rtl_text}") + + result, errors = bundle.format_pattern(msg_id) + + event(f"use_isolating={use_isolating}") + assert errors == () + # Text should appear (possibly with isolating chars) + assert rtl_text in result or rtl_text in result.replace("\u2068", "").replace("\u2069", "") + + def test_mixed_ltr_rtl_text(self) -> None: + """Mixed LTR and RTL text is handled.""" + bundle = FluentBundle("ar", use_isolating=True) + bundle.add_resource("msg = Hello مرحبا World") + + result, errors = bundle.format_pattern("msg") + + assert errors == () + assert "Hello" in result.replace("\u2068", "").replace("\u2069", "") + assert "مرحبا" in result.replace("\u2068", "").replace("\u2069", "") + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + rtl_value=st.sampled_from(["مرحبا", "שלום"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_rtl_variables_with_isolating( + self, msg_id: str, var_name: str, rtl_value: str + ) -> None: + """PROPERTY: RTL variables are isolated correctly.""" + bundle = FluentBundle("ar", use_isolating=True) + bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") + + result, errors = bundle.format_pattern(msg_id, {var_name: rtl_value}) + + event(f"rtl_value_len={len(rtl_value)}") + assert errors == () + # RTL value should appear + assert rtl_value in result.replace("\u2068", "").replace("\u2069", "") + + +# ============================================================================ +# ADDITIONAL ERROR RECOVERY +# ============================================================================ + +class TestAdditionalErrorRecovery: + """Property tests for additional error recovery scenarios.""" + + @given( + depth=st.integers(min_value=1, max_value=5), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_deeply_nested_missing_references(self, depth: int) -> None: + """PROPERTY: Deeply nested missing references are handled.""" + bundle = FluentBundle("en", strict=False) + + # Create chain with missing link + messages = [f"msg{i} = {{ msg{i+1} }}" for i in range(depth)] + # Don't add the final message - it's missing + ftl = "\n".join(messages) + + bundle.add_resource(ftl) + + result, errors = bundle.format_pattern("msg0") + + event(f"depth={depth}") + # Should have errors but not crash + assert len(errors) > 0 + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + func_name=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_unknown_function_recovery( + self, msg_id: str, func_name: str + ) -> None: + """PROPERTY: Unknown functions are handled gracefully.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource(f"{msg_id} = {{ {func_name.upper()}($var) }}") + + result, _errors = bundle.format_pattern(msg_id, {"var": 123}) + + event(f"func_name_len={len(func_name)}") + # Should return fallback without crashing + assert isinstance(result, str) + + def test_malformed_select_expression_recovery(self) -> None: + """Malformed select expressions are handled.""" + bundle = FluentBundle("en") + + # Try to add malformed select (parser should handle or reject) + with contextlib.suppress(Exception): + bundle.add_resource( + """ +msg = { $var -> + [one One value + *[other] Other value +} +""" + ) + + # Bundle should still be usable + bundle.add_resource("valid = Works fine") + _result, errors = bundle.format_pattern("valid") + assert errors == () + + @given( + msg_id=ftl_identifiers, + invalid_escape=st.sampled_from([r"\x", r"\u", r"\uGGGG"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_invalid_escape_sequence_recovery( + self, msg_id: str, invalid_escape: str + ) -> None: + """PROPERTY: Invalid escape sequences are handled.""" + bundle = FluentBundle("en") + + # Try to add message with invalid escape + with contextlib.suppress(Exception): + bundle.add_resource(f'{msg_id} = "Text {invalid_escape} more"') + + event(f"escape_seq={invalid_escape}") + # Bundle should still work + assert isinstance(bundle.locale, str) + + def test_concurrent_formatting_safety(self) -> None: + """Bundle handles concurrent formatting safely.""" + bundle = FluentBundle("en") + bundle.add_resource("msg = Hello World") + + # Format same message multiple times (simulating concurrent access) + results = [bundle.format_pattern("msg")[0] for _ in range(10)] + + # All results should be identical + assert all(r == results[0] for r in results) + assert all("Hello World" in r for r in results) + + +# ============================================================================ +# MESSAGE PATTERN COMPLEXITY +# ============================================================================ + +class TestMessagePatternComplexity: + """Property tests for complex message patterns.""" + + def test_deeply_nested_placeables(self) -> None: + """Deeply nested placeables work.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +-inner = Inner +-middle = Middle { -inner } +-outer = Outer { -middle } +msg = { -outer } +""" + ) + + result, errors = bundle.format_pattern("msg") + + assert errors == () + assert "Outer" in result + assert "Middle" in result + assert "Inner" in result + + @given( + msg_id=ftl_identifiers, + var_count=st.integers(min_value=3, max_value=8), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_many_placeables_in_pattern( + self, msg_id: str, var_count: int + ) -> None: + """PROPERTY: Patterns with many placeables work.""" + bundle = FluentBundle("en") + + # Build pattern with many placeables + placeables = " ".join([f"{{ $var{i} }}" for i in range(var_count)]) + bundle.add_resource(f"{msg_id} = {placeables}") + + # Provide all variables + args: dict[str, str | int | bool] = {f"var{i}": f"V{i}" for i in range(var_count)} + + result, errors = bundle.format_pattern(msg_id, args) + + event(f"var_count={var_count}") + assert errors == () + # All values should appear + for i in range(var_count): + assert f"V{i}" in result + + def test_complex_term_with_selectors(self) -> None: + """Complex terms with selectors work.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +-brand = FTL + .full = FTLLexEngine + .short = FTL + +msg = { $variant -> + [full] { -brand.full } + *[short] { -brand.short } +} +""" + ) + + result, errors = bundle.format_pattern("msg", {"variant": "full"}) + + assert errors == () + assert "FTLLexEngine" in result + + @given( + msg_id=ftl_identifiers, + text_segments=st.lists(ftl_safe_text, min_size=2, max_size=5), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_alternating_text_and_placeables( + self, msg_id: str, text_segments: list[str] + ) -> None: + """PROPERTY: Alternating text and placeables work.""" + assume(all(len(seg) > 0 for seg in text_segments)) + + bundle = FluentBundle("en") + + # Build pattern: text0 { $v0 } text1 { $v1 } ... + pattern_parts = [] + args: dict[str, str | int | bool] = {} + for i, text in enumerate(text_segments): + pattern_parts.append(text) + if i < len(text_segments) - 1: + pattern_parts.append(f"{{ $v{i} }}") + args[f"v{i}"] = f"VAR{i}" + + pattern = " ".join(pattern_parts) + bundle.add_resource(f"{msg_id} = {pattern}") + + result, errors = bundle.format_pattern(msg_id, args) + + event(f"segment_count={len(text_segments)}") + assert errors == () + # All text segments should appear + for text in text_segments: + assert text in result + + def test_message_with_all_feature_types(self) -> None: + """Message using all feature types works.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +-brand = FTLLexEngine + +msg = Welcome to { -brand }! + You have { $count -> + [0] no items + [1] one item + *[other] { NUMBER($count) } items + }. + Price: { CURRENCY($price, currency: "USD") } + + .title = { -brand } - Message System +""" + ) + + result, errors = bundle.format_pattern( + "msg", + {"count": 5, "price": Decimal("99.99")} + ) + + assert errors == () + assert "FTLLexEngine" in result + assert "5" in result or "items" in result + + +# ============================================================================ +# FUNCTION ARGUMENT EDGE CASES +# ============================================================================ + +class TestFunctionArgumentEdgeCases: + """Property tests for function argument edge cases.""" + + @given( + msg_id=ftl_identifiers, + number=st.decimals( + min_value=Decimal("0.0"), + max_value=Decimal("1.0"), + allow_nan=False, + allow_infinity=False, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_number_function_small_values( + self, msg_id: str, number: Decimal + ) -> None: + """PROPERTY: NUMBER handles small decimal values.""" + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id} = {{ NUMBER($num, minimumFractionDigits: 4) }}" + ) + + result, errors = bundle.format_pattern(msg_id, {"num": number}) + + event("num_magnitude=small") + assert errors == () + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + number=st.decimals( + min_value=Decimal(1000000), + max_value=Decimal(1000000000), + allow_nan=False, + allow_infinity=False, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_number_function_large_values( + self, msg_id: str, number: Decimal + ) -> None: + """PROPERTY: NUMBER handles large values.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {{ NUMBER($num) }}") + + result, errors = bundle.format_pattern(msg_id, {"num": number}) + + event("num_magnitude=large") + assert errors == () + assert isinstance(result, str) + + def test_number_function_negative_zero(self) -> None: + """NUMBER handles negative zero correctly.""" + bundle = FluentBundle("en") + bundle.add_resource("msg = { NUMBER($num) }") + + result, errors = bundle.format_pattern("msg", {"num": Decimal("-0")}) + + assert errors == () + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + amount=st.decimals( + min_value=Decimal("0.001"), + max_value=Decimal("0.01"), + allow_nan=False, + allow_infinity=False, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_currency_function_tiny_amounts( + self, msg_id: str, amount: Decimal + ) -> None: + """PROPERTY: CURRENCY handles very small amounts.""" + bundle = FluentBundle("en") + bundle.add_resource( + f'{msg_id} = {{ CURRENCY($amt, currency: "USD") }}' + ) + + result, errors = bundle.format_pattern(msg_id, {"amt": amount}) + + event("amount_magnitude=tiny") + assert not errors + + # May have errors depending on currency support + assert isinstance(result, str) + + def test_function_with_missing_required_option(self) -> None: + """Function with missing required option is handled.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource("msg = { CURRENCY($amt) }") + + result, _errors = bundle.format_pattern("msg", {"amt": Decimal("99.99")}) + + # Should handle missing currency option + assert isinstance(result, str) + + +# ============================================================================ +# LOCALE FALLBACK BEHAVIOR +# ============================================================================ diff --git a/tests/test_runtime_bundle_property_core.py b/tests/test_runtime_bundle_property_core.py new file mode 100644 index 00000000..c69bf706 --- /dev/null +++ b/tests/test_runtime_bundle_property_core.py @@ -0,0 +1,739 @@ +"""Hypothesis property-based tests for runtime.bundle: FluentBundle operations.""" + +from __future__ import annotations + +import logging +from decimal import Decimal + +import pytest +from hypothesis import HealthCheck, assume, event, given, settings +from hypothesis import strategies as st + +from ftllexengine import FluentBundle +from ftllexengine.core.locale_utils import normalize_locale + +# ============================================================================ +# HYPOTHESIS STRATEGIES +# ============================================================================ + + +# Strategy for valid FTL identifiers (using st.from_regex per hypothesis.md) +ftl_identifiers = st.from_regex(r"[a-z][a-z0-9_-]*", fullmatch=True) + + +# Strategy for FTL-safe text content (no special characters that break parsing) +ftl_safe_text = st.text( + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), # Control and surrogate + blacklist_characters="{}[]*$->\n\r", # FTL syntax characters + ), + min_size=0, + max_size=100, +).filter(lambda s: s.strip() == s and len(s.strip()) > 0 if s else True) + + +# Strategy for locale codes +locale_codes = st.sampled_from([ + "en", "en_US", "en_GB", + "lv", "lv_LV", + "de", "de_DE", + "pl", "pl_PL", + "ru", "ru_RU", + "fr", "fr_FR", +]) + +log_source_paths = st.from_regex( + r"[A-Za-z0-9_-][A-Za-z0-9_. /-]{0,31}", + fullmatch=True, +) + + +# ============================================================================ +# PROPERTY TESTS - TERM ATTRIBUTES IN CYCLE DETECTION +# ============================================================================ + + +class TestTermAttributesCycleDetection: + """Property tests for term attributes in cycle detection (line 251).""" + + def test_term_with_attributes_no_cycles(self) -> None: + """Term with attributes triggers cycle detection path (line 251).""" + bundle = FluentBundle("en") + + # Add term with multiple attributes + ftl = """ +-brand = Acme Corp + .legal = Acme Corporation Ltd. + .short = Acme + .marketing = The Acme Brand + +welcome = Welcome to { -brand }! +legal = { -brand.legal } +""" + bundle.add_resource(ftl) + + # Should successfully add and format + result, errors = bundle.format_pattern("legal") + assert errors == () + assert "Acme Corporation" in result + + def test_term_attributes_with_term_references(self) -> None: + """Term attributes referencing other terms (line 251).""" + bundle = FluentBundle("en") + + # Term attributes that reference other terms + ftl = """ +-company-name = Acme Corp +-brand = { -company-name } + .full = { -company-name } International + .legal = { -company-name } Ltd. + +welcome = { -brand.full } +""" + bundle.add_resource(ftl) + + result, errors = bundle.format_pattern("welcome") + assert errors == () + assert "Acme" in result + + @given(attr_count=st.integers(min_value=1, max_value=5)) # Keep small bound for memory + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_term_multiple_attributes_property(self, attr_count: int) -> None: + """Property: Terms with N attributes are validated correctly.""" + bundle = FluentBundle("en") + + # Generate term with multiple attributes + attrs = "\n".join(f" .attr{i} = Value {i}" for i in range(attr_count)) + ftl = f""" +-term = Base Value +{attrs} + +msg = {{ -term }} +""" + bundle.add_resource(ftl) + + # Should successfully parse and validate + result, errors = bundle.format_pattern("msg") + event(f"attr_count={attr_count}") + assert errors == () + assert "Base Value" in result + event("outcome=term_multi_attr_valid") + + +# ============================================================================ +# PROPERTY TESTS - SOURCE PATH ERROR LOGGING +# ============================================================================ + +class TestSourcePathErrorLogging: + """Property tests for source_path in error/warning logging.""" + + def test_junk_with_source_path_logging(self, caplog: pytest.LogCaptureFixture) -> None: + """Junk entry with source_path triggers warning log (line 333).""" + bundle = FluentBundle("en") + + # Add invalid FTL that produces Junk entries + # Parser will create Junk for invalid syntax + invalid_ftl = "@@@ invalid syntax $$$ {{{ [[[" + + with caplog.at_level(logging.WARNING): + try: # noqa: SIM105 - explicit except-pass preserves state machine intent + bundle.add_resource(invalid_ftl, source_path="test_file.ftl") + except Exception: # pylint: disable=broad-exception-caught + pass + + # Check that warning was logged with source_path + # Line 333 logs: "Syntax error in %s: %s", source_path, entry.content[:100] + # Junk may or may not trigger warning depending on parser behavior + # This tests that source_path is available when needed + # Verify either warning was logged or junk was handled gracefully + assert len(caplog.records) >= 0 # Logging system functional + + def test_parse_error_with_source_path_logging(self, caplog: pytest.LogCaptureFixture) -> None: + """Parse error with source_path triggers error log (line 363).""" + bundle = FluentBundle("en") + + # Add completely malformed FTL that causes critical parse error + # Use control characters that definitely break the parser + malformed_ftl = "message = \x00\x01\x02 invalid" + + with caplog.at_level(logging.ERROR): + try: # noqa: SIM105 - explicit except-pass preserves state machine intent + bundle.add_resource(malformed_ftl, source_path="error_file.ftl") + except Exception: # pylint: disable=broad-exception-caught + pass + + # Check that error was logged with source_path + # Line 363 logs: "Failed to parse resource %s: %s", source_path, e + log_messages = [record.message for record in caplog.records if record.levelname == "ERROR"] + # If there was a critical parse error, source_path should be in logs + if log_messages: + assert any("error_file.ftl" in msg for msg in log_messages) + + @given(locale=locale_codes, filename=log_source_paths) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_source_path_appears_in_logs_property( + self, + locale: str, + filename: str, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Property: source_path always appears in error/warning logs when provided.""" + bundle = FluentBundle(locale) + + invalid_ftl = "invalid syntax $$$" + + with caplog.at_level(logging.WARNING): + try: # noqa: SIM105 - explicit except-pass preserves state machine intent + bundle.add_resource(invalid_ftl, source_path=filename) + except Exception: # pylint: disable=broad-exception-caught + pass + + # source_path should appear in at least one log record + if caplog.records: + messages = [record.message for record in caplog.records] + event(f"filename_len={len(filename)}") + assert any(filename in msg for msg in messages) + event("outcome=source_path_in_logs") + + +# ============================================================================ +# PROPERTY TESTS - MESSAGE VALIDATION WARNINGS +# ============================================================================ + +class TestMessageValidationWarnings: + """Property tests for message validation warnings.""" + + def test_message_without_value_or_attributes_warning(self) -> None: + """Message with neither value nor attributes triggers warning (line 423).""" + bundle = FluentBundle("en") + + # This is actually invalid FTL syntax - a message MUST have value or attributes + # But we can test the validation logic by using validate_resource + + # Create FTL that parser might accept but validator flags + ftl = """ +valid-message = Hello +""" + # Try to construct invalid message programmatically via validation + result = bundle.validate_resource(ftl) + + # Valid FTL should have no errors or warnings + assert result.errors == () + + @given( + msg_id=ftl_identifiers, + has_value=st.booleans(), + has_attributes=st.booleans(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_message_value_attribute_combinations_property( + self, + msg_id: str, + has_value: bool, + has_attributes: bool, + ) -> None: + """Property: Messages must have value or attributes.""" + assume(has_value or has_attributes) # Skip invalid case + + event(f"has_value={has_value}") + event(f"has_attributes={has_attributes}") + bundle = FluentBundle("en") + + # Construct valid FTL + if has_value and has_attributes: + ftl = f"{msg_id} = Value\n .attr = Attribute" + elif has_value: + ftl = f"{msg_id} = Value" + else: + # Attributes only + ftl = f"{msg_id} =\n .attr = Attribute" + + bundle.add_resource(ftl) + + # Should successfully format (with value or attribute access) + if has_value: + result, errors = bundle.format_pattern(msg_id) + + assert not errors + assert isinstance(result, str) + else: + # Attributes-only message - use format_pattern with attribute selector + result, errors = bundle.format_pattern( + msg_id, + args=None, + attribute="attr", + ) + + event(f"id_len={len(msg_id)}") + assert not errors + assert isinstance(result, str) + event("outcome=attr_only_message_valid") + + +# ============================================================================ +# PROPERTY TESTS - VALIDATION ERROR HANDLING +# ============================================================================ + +class TestValidationErrorHandling: + """Property tests for validate_resource error handling (lines 488-493).""" + + def test_validate_resource_critical_syntax_error(self) -> None: + """Critical syntax error in validate_resource returns Junk (lines 488-493).""" + bundle = FluentBundle("en") + + # Severely malformed FTL + malformed_ftl = "this is not FTL at all $$$ [[[ {{{ \x00\x01\x02" + + # Parser uses Junk nodes for syntax errors (robustness principle) + result = bundle.validate_resource(malformed_ftl) + + # Should have errors (Junk entries) + assert len(result.errors) > 0 + + @given( + invalid_char=st.sampled_from(["\x00", "\x01", "\x02", "\x03", "\x04", "\x1f"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_validate_malformed_ftl_property(self, invalid_char: str) -> None: + """Property: Validating malformed FTL returns errors, not exceptions.""" + bundle = FluentBundle("en") + + # Construct FTL with invalid control characters + malformed_ftl = f"message = Value {invalid_char} text" + + # validate_resource should handle gracefully + result = bundle.validate_resource(malformed_ftl) + + event(f"malformed_len={len(malformed_ftl)}") + # Should return ValidationResult (not raise exception) + assert hasattr(result, "errors") + assert hasattr(result, "warnings") + event("outcome=malformed_ftl_validated") + + def test_validate_empty_resource(self) -> None: + """Validating empty resource returns no errors.""" + bundle = FluentBundle("en") + + result = bundle.validate_resource("") + + assert result.errors == () + assert result.warnings == () + + def test_validate_whitespace_only_resource(self) -> None: + """Validating whitespace-only resource handles gracefully.""" + bundle = FluentBundle("en") + + result = bundle.validate_resource(" \n\n \t\t \n ") + + # Whitespace may or may not trigger parse errors depending on parser + # What matters is that it returns a ValidationResult without crashing + assert hasattr(result, "errors") + assert hasattr(result, "warnings") + assert isinstance(result.errors, tuple) + assert isinstance(result.warnings, tuple) + + @given(valid_ftl=st.text(min_size=1)) # Remove arbitrary max + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_validate_arbitrary_text_never_crashes(self, valid_ftl: str) -> None: + """Property: validate_resource never crashes, even on arbitrary text.""" + bundle = FluentBundle("en") + + # Should always return ValidationResult, never raise + result = bundle.validate_resource(valid_ftl) + + event(f"text_len={len(valid_ftl)}") + assert hasattr(result, "errors") + assert hasattr(result, "warnings") + assert isinstance(result.errors, tuple) + assert isinstance(result.warnings, tuple) + event("outcome=validate_never_crashes") + + +# ============================================================================ +# PROPERTY TESTS - FINANCIAL USE CASES +# ============================================================================ + +class TestFinancialBundleOperations: + """Financial-grade property tests for bundle operations.""" + + @given( + amount=st.decimals(min_value=Decimal("0.01"), allow_nan=False, allow_infinity=False), + currency=st.sampled_from(["EUR", "USD", "GBP", "JPY"]), + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_currency_formatting_never_crashes( + self, + amount: Decimal, + currency: str, + locale: str, + ) -> None: + """Property: Currency formatting never crashes for valid inputs.""" + bundle = FluentBundle(locale, use_isolating=False, strict=False) + + bundle.add_resource(f'price = {{ CURRENCY($amount, currency: "{currency}") }}') + + result, _errors = bundle.format_pattern("price", {"amount": amount}) + + event(f"currency={currency}") + # Should always return string, even if there are errors + assert isinstance(result, str) + event("outcome=currency_format_no_crash") + + @given( + # Remove arbitrary max - let Hypothesis explore + quantity=st.integers(min_value=0), + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_plural_quantity_formatting( + self, + quantity: int, + locale: str, + ) -> None: + """Property: Plural formatting works for all quantities.""" + bundle = FluentBundle(locale, use_isolating=False) + + bundle.add_resource(""" +items = { $count -> + [0] No items + [1] One item + *[other] { $count } items +} +""") + + result, errors = bundle.format_pattern("items", {"count": quantity}) + + event(f"quantity={quantity}") + assert isinstance(result, str) + assert errors == () + event("outcome=plural_quantity_format") + + @given( + vat_rate=st.decimals( + min_value=Decimal("0.0"), max_value=Decimal("1.0"), + allow_nan=False, allow_infinity=False, + ), + net_amount=st.decimals(min_value=Decimal("0.01"), allow_nan=False, allow_infinity=False), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_vat_calculation_formatting( + self, + vat_rate: Decimal, + net_amount: Decimal, + ) -> None: + """Property: VAT calculations format correctly.""" + bundle = FluentBundle("lv_LV", use_isolating=False, strict=False) + + bundle.add_resource("vat = VAT: { NUMBER($vat, minimumFractionDigits: 2) }") + + vat_amount = net_amount * vat_rate + + result, _errors = bundle.format_pattern("vat", {"vat": vat_amount}) + + event(f"vat_rate={vat_rate:.2f}") + assert isinstance(result, str) + assert "VAT:" in result + # Should have properly formatted number + assert len(result) > 5 + event("outcome=vat_calc_format") + + +# ============================================================================ +# PROPERTY TESTS - BUNDLE ROBUSTNESS +# ============================================================================ + +class TestBundleRobustness: + """Property tests for bundle robustness and error recovery.""" + + @given( + msg_count=st.integers(min_value=1, max_value=50), # Keep practical bound + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_large_resource_handling(self, msg_count: int, locale: str) -> None: + """Property: Bundle handles resources with many messages.""" + bundle = FluentBundle(locale) + + # Generate large FTL resource + messages = [f"msg{i} = Message {i}" for i in range(msg_count)] + ftl = "\n".join(messages) + + bundle.add_resource(ftl) + + # Should successfully format first and last messages + result_first, errors_first = bundle.format_pattern("msg0") + assert errors_first == () + assert "Message 0" in result_first + + result_last, errors_last = bundle.format_pattern(f"msg{msg_count - 1}") + event(f"msg_count={msg_count}") + assert errors_last == () + assert f"Message {msg_count - 1}" in result_last + event("outcome=large_resource_handled") + + @given( + locale1=locale_codes, + locale2=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_multiple_bundles_isolation(self, locale1: str, locale2: str) -> None: + """Property: Multiple bundles maintain isolation.""" + bundle1 = FluentBundle(locale1) + bundle2 = FluentBundle(locale2) + + bundle1.add_resource("greeting = Hello from bundle 1") + bundle2.add_resource("greeting = Hello from bundle 2") + + result1, _ = bundle1.format_pattern("greeting") + result2, _ = bundle2.format_pattern("greeting") + + event(f"locales={locale1},{locale2}") + # Results should be different + assert "bundle 1" in result1 + assert "bundle 2" in result2 + event("outcome=multi_bundle_isolation") + + @given(text=ftl_safe_text) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_arbitrary_text_values_never_crash(self, text: str) -> None: + """Property: Bundle handles arbitrary text values safely.""" + assume(len(text) > 0) + assume(text.isprintable() or text.isspace()) + + bundle = FluentBundle("en") + + # Create message with arbitrary text + # Escape curly braces to prevent FTL syntax errors + safe_text = text.replace("{", "{{").replace("}", "}}") + ftl = f"msg = {safe_text}" + + try: + bundle.add_resource(ftl) + result, _ = bundle.format_pattern("msg") + event(f"text_len={len(text)}") + assert isinstance(result, str) + event("outcome=arbitrary_text_no_crash") + except Exception: # pylint: disable=broad-exception-caught + # Some text might be invalid FTL, that's OK + pass + + +# ============================================================================ +# PROPERTY TESTS - EDGE CASES +# ============================================================================ + +class TestBundleEdgeCases: + """Property tests for bundle edge cases.""" + + def test_empty_bundle_operations(self) -> None: + """Empty bundle operations work correctly.""" + bundle = FluentBundle("en", strict=False) + + # Validate empty resource + result = bundle.validate_resource("") + assert result.errors == () + assert result.warnings == () + + # Format non-existent message returns fallback + result_str, errors = bundle.format_pattern("nonexistent") + assert isinstance(result_str, str) + assert len(errors) > 0 # Should have error + + @given( + locale=st.text( + alphabet=st.characters(whitelist_categories=("Ll", "Lu")), + min_size=2, + max_size=8, + ) + ) + @settings( + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.filter_too_much, + ] + ) + def test_arbitrary_locale_codes_accepted(self, locale: str) -> None: + """Property: Bundle accepts arbitrary locale codes.""" + assume(locale.isalpha()) + + # Should not crash, even with non-standard locale + try: + bundle = FluentBundle(locale) + event(f"locale_len={len(locale)}") + assert bundle.locale == normalize_locale(locale) + event("outcome=arbitrary_locale_accepted") + except Exception: # pylint: disable=broad-exception-caught + # Some locales might be rejected by Babel, that's OK + pass + + def test_unicode_handling_in_messages(self) -> None: + """Bundle handles Unicode correctly in messages.""" + bundle = FluentBundle("en") + + # Add message with various Unicode characters + ftl = """ +emoji = Hello 👋 World 🌍 +arabic = مرحبا +chinese = 你好 +math = √(x²+y²) +""" + bundle.add_resource(ftl) + + # All should format correctly + for msg_id in ["emoji", "arabic", "chinese", "math"]: + result, errors = bundle.format_pattern(msg_id) + assert errors == () + assert len(result) > 0 + + +# ============================================================================ +# RESOURCE MANAGEMENT +# ============================================================================ + +class TestResourceManagement: + """Property tests for resource management operations.""" + + @given( + msg_count=st.integers(min_value=1, max_value=50), # Keep practical bound + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_add_multiple_resources(self, msg_count: int, locale: str) -> None: + """PROPERTY: Adding multiple resources accumulates messages.""" + bundle = FluentBundle(locale) + + # Add messages in separate resources + for i in range(msg_count): + bundle.add_resource(f"msg{i} = Message {i}") + + # All messages should be accessible + for i in range(msg_count): + result, errors = bundle.format_pattern(f"msg{i}") + assert errors == () + assert f"Message {i}" in result + + event(f"resource_count={msg_count}") + event("outcome=multi_resource_accumulated") + + @given( + msg_id=ftl_identifiers, + value1=ftl_safe_text, + value2=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_overlapping_messages_last_wins( + self, msg_id: str, value1: str, value2: str + ) -> None: + """PROPERTY: Later resources override earlier messages.""" + assume(value1 != value2) + assume(len(value1) > 0 and len(value2) > 0) + + bundle = FluentBundle("en") + + bundle.add_resource(f"{msg_id} = {value1}") + bundle.add_resource(f"{msg_id} = {value2}") + + result, _ = bundle.format_pattern(msg_id) + + event(f"winner_len={len(value2)}") + # Second value should win + assert value2 in result + event("outcome=overlapping_msg_last_wins") + + @given( + resource_count=st.integers(min_value=1, max_value=15), # Keep practical bound + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_empty_resources_handled(self, resource_count: int) -> None: + """PROPERTY: Empty resources don't affect bundle.""" + bundle = FluentBundle("en") + + # Add some empty resources + for _ in range(resource_count): + bundle.add_resource("") + + bundle.add_resource("msg = Hello") + + result, errors = bundle.format_pattern("msg") + event(f"empty_resource_count={resource_count}") + assert errors == () + assert "Hello" in result + event("outcome=empty_resource_handled") + + +# ============================================================================ +# MESSAGE FORMATTING +# ============================================================================ + +class TestMessageFormatting: + """Property tests for message formatting operations.""" + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_format_value_simple_message(self, msg_id: str, text: str) -> None: + """PROPERTY: format_value returns message value.""" + assume(len(text) > 0) + event(f"msg_id_len={len(msg_id)}") + event(f"text_len={len(text)}") + + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {text}") + + result, errors = bundle.format_pattern(msg_id) + + assert errors == () + assert text in result + + @given( + msg_id=ftl_identifiers, + attr_name=ftl_identifiers, + attr_value=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_format_pattern_with_attribute( + self, msg_id: str, attr_name: str, attr_value: str + ) -> None: + """PROPERTY: format_pattern can access attributes.""" + assume(len(attr_value) > 0) + + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id} = Main value\n" + f" .{attr_name} = {attr_value}" + ) + + result, errors = bundle.format_pattern(msg_id, attribute=attr_name) + + event(f"attr_name_len={len(attr_name)}") + assert errors == () + assert attr_value in result + event("outcome=format_pattern_attr") + + @given( + msg_id=ftl_identifiers, + locale=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_format_missing_message_returns_fallback( + self, msg_id: str, locale: str + ) -> None: + """PROPERTY: Formatting missing message returns fallback.""" + bundle = FluentBundle(locale, strict=False) + + result, errors = bundle.format_pattern(msg_id) + + event(f"missing_id_len={len(msg_id)}") + # Should have errors + assert len(errors) > 0 + # Should return fallback string + assert isinstance(result, str) + event("outcome=format_missing_msg_fallback") + + +# ============================================================================ +# VARIABLE SUBSTITUTION +# ============================================================================ diff --git a/tests/test_runtime_bundle_property_references.py b/tests/test_runtime_bundle_property_references.py new file mode 100644 index 00000000..5c9f4ab5 --- /dev/null +++ b/tests/test_runtime_bundle_property_references.py @@ -0,0 +1,835 @@ +"""Hypothesis property-based tests for runtime.bundle: FluentBundle operations.""" + +from __future__ import annotations + +import contextlib +from decimal import Decimal + +from hypothesis import HealthCheck, assume, event, given, settings +from hypothesis import strategies as st + +from ftllexengine import FluentBundle + +# ============================================================================ +# HYPOTHESIS STRATEGIES +# ============================================================================ + + +# Strategy for valid FTL identifiers (using st.from_regex per hypothesis.md) +ftl_identifiers = st.from_regex(r"[a-z][a-z0-9_-]*", fullmatch=True) + + +# Strategy for FTL-safe text content (no special characters that break parsing) +ftl_safe_text = st.text( + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), # Control and surrogate + blacklist_characters="{}[]*$->\n\r", # FTL syntax characters + ), + min_size=0, + max_size=100, +).filter(lambda s: s.strip() == s and len(s.strip()) > 0 if s else True) + + +# Strategy for locale codes +locale_codes = st.sampled_from([ + "en", "en_US", "en_GB", + "lv", "lv_LV", + "de", "de_DE", + "pl", "pl_PL", + "ru", "ru_RU", + "fr", "fr_FR", +]) + +log_source_paths = st.from_regex( + r"[A-Za-z0-9_-][A-Za-z0-9_. /-]{0,31}", + fullmatch=True, +) + + +# ============================================================================ +# PROPERTY TESTS - TERM ATTRIBUTES IN CYCLE DETECTION +# ============================================================================ + + +class TestVariableSubstitution: + """Property tests for variable substitution.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + # Remove arbitrary bounds + var_value=st.integers(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_integer_variable_substitution( + self, msg_id: str, var_name: str, var_value: int + ) -> None: + """PROPERTY: Integer variables are substituted correctly.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") + + result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) + + event(f"int_val={var_value}") + assert errors == () + assert str(var_value) in result + event("outcome=int_var_subst") + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + var_value=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_string_variable_substitution( + self, msg_id: str, var_name: str, var_value: str + ) -> None: + """PROPERTY: String variables are substituted correctly.""" + assume(len(var_value) > 0) + + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") + + result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) + + event(f"str_val_len={len(var_value)}") + assert errors == () + assert var_value in result + event("outcome=str_var_subst") + + @given( + msg_id=ftl_identifiers, + # Keep practical bound for performance + var_count=st.integers(min_value=1, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_multiple_variable_substitution( + self, msg_id: str, var_count: int + ) -> None: + """PROPERTY: Multiple variables are substituted correctly.""" + bundle = FluentBundle("en") + + # Build FTL with multiple variables + vars_ftl = " ".join([f"{{ $var{i} }}" for i in range(var_count)]) + bundle.add_resource(f"{msg_id} = {vars_ftl}") + + # Build args dict + args: dict[str, int | str | bool] = {f"var{i}": i for i in range(var_count)} + + result, errors = bundle.format_pattern(msg_id, args) + + event(f"var_count={var_count}") + assert errors == () + # All variable values should appear + for i in range(var_count): + assert str(i) in result + event("outcome=multi_var_subst") + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_missing_variable_generates_error( + self, msg_id: str, var_name: str + ) -> None: + """PROPERTY: Missing variables generate errors.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource(f"{msg_id} = Value: {{ ${var_name} }}") + + result, errors = bundle.format_pattern(msg_id, {}) + + event(f"missing_var_id_len={len(var_name)}") + # Should have error for missing variable + assert len(errors) > 0 + assert isinstance(result, str) + event("outcome=missing_var_error") + + +# ============================================================================ +# FUNCTION CALLS +# ============================================================================ + +class TestFunctionCalls: + """Property tests for built-in function calls.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + number=st.decimals( + min_value=Decimal(-1000), + max_value=Decimal(1000), + allow_nan=False, + allow_infinity=False, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_number_function_formatting( + self, msg_id: str, var_name: str, number: Decimal + ) -> None: + """PROPERTY: NUMBER function formats numbers.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {{ NUMBER(${var_name}) }}") + + result, errors = bundle.format_pattern(msg_id, {var_name: number}) + + event(f"num={number}") + assert errors == () + assert isinstance(result, str) + assert len(result) > 0 + event("outcome=num_func_format") + + @given( + msg_id=ftl_identifiers, + currency=st.sampled_from(["USD", "EUR", "GBP", "JPY"]), + amount=st.decimals( + min_value=Decimal("0.01"), + max_value=Decimal(10000), + allow_nan=False, + allow_infinity=False, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_currency_function_formatting( + self, msg_id: str, currency: str, amount: Decimal + ) -> None: + """PROPERTY: CURRENCY function formats currency values.""" + bundle = FluentBundle("en") + bundle.add_resource( + f'{msg_id} = {{ CURRENCY($amt, currency: "{currency}") }}' + ) + + result, errors = bundle.format_pattern(msg_id, {"amt": amount}) + + event(f"currency={currency}") + assert not errors + + # May have errors depending on currency support + assert isinstance(result, str) + assert len(result) > 0 + event("outcome=currency_func_format") + + +# ============================================================================ +# TERM RESOLUTION +# ============================================================================ + +class TestTermResolution: + """Property tests for term resolution.""" + + @given( + term_id=ftl_identifiers, + term_value=ftl_safe_text, + msg_id=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_term_reference_resolution( + self, term_id: str, term_value: str, msg_id: str + ) -> None: + """PROPERTY: Terms are resolved in messages.""" + assume(len(term_value) > 0) + assume(term_id != msg_id) + + bundle = FluentBundle("en") + bundle.add_resource( + f"-{term_id} = {term_value}\n" + f"{msg_id} = {{ -{term_id} }}" + ) + + result, errors = bundle.format_pattern(msg_id) + + event(f"id_len={len(term_id)}") + assert errors == () + assert term_value in result + event("outcome=term_ref_resolution") + + @given( + term_id=ftl_identifiers, + attr_name=ftl_identifiers, + attr_value=ftl_safe_text, + msg_id=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_term_attribute_resolution( + self, term_id: str, attr_name: str, attr_value: str, msg_id: str + ) -> None: + """PROPERTY: Term attributes are resolved.""" + assume(len(attr_value) > 0) + assume(term_id != msg_id) + + bundle = FluentBundle("en") + bundle.add_resource( + f"-{term_id} = Base\n" + f" .{attr_name} = {attr_value}\n" + f"{msg_id} = {{ -{term_id}.{attr_name} }}" + ) + + result, errors = bundle.format_pattern(msg_id) + + event(f"attr_len={len(attr_value)}") + assert errors == () + assert attr_value in result + event("outcome=term_attr_resolution") + + +# ============================================================================ +# MESSAGE REFERENCES +# ============================================================================ + +class TestMessageReferences: + """Property tests for message references.""" + + @given( + msg_id1=ftl_identifiers, + msg_id2=ftl_identifiers, + value=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_message_reference_resolution( + self, msg_id1: str, msg_id2: str, value: str + ) -> None: + """PROPERTY: Message references are resolved.""" + assume(len(value) > 0) + assume(msg_id1 != msg_id2) + + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id1} = {value}\n" + f"{msg_id2} = Ref: {{ {msg_id1} }}" + ) + + result, errors = bundle.format_pattern(msg_id2) + + event(f"val_len={len(value)}") + assert errors == () + assert value in result + event("outcome=msg_ref_resolution") + + +# ============================================================================ +# ATTRIBUTE ACCESS +# ============================================================================ + +class TestAttributeAccess: + """Property tests for attribute access.""" + + @given( + msg_id=ftl_identifiers, + attr_count=st.integers(min_value=1, max_value=10), # Keep practical bound + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_multiple_attributes_accessible( + self, msg_id: str, attr_count: int + ) -> None: + """PROPERTY: All attributes are accessible.""" + bundle = FluentBundle("en") + + # Build message with multiple attributes + attrs = "\n".join([f" .attr{i} = Value{i}" for i in range(attr_count)]) + bundle.add_resource(f"{msg_id} = Main\n{attrs}") + + # Access each attribute + for i in range(attr_count): + result, errors = bundle.format_pattern(msg_id, attribute=f"attr{i}") + assert errors == () + assert f"Value{i}" in result + + event(f"attr_count={attr_count}") + event("outcome=multi_attr_accessible") + + +# ============================================================================ +# LOCALE HANDLING +# ============================================================================ + +class TestLocaleHandling: + """Property tests for locale handling.""" + + @given( + locale1=locale_codes, + locale2=locale_codes, + msg_id=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_different_locales_independent( + self, locale1: str, locale2: str, msg_id: str + ) -> None: + """PROPERTY: Different locale bundles are independent.""" + assume(locale1 != locale2) + + bundle1 = FluentBundle(locale1) + bundle2 = FluentBundle(locale2) + + bundle1.add_resource(f"{msg_id} = Locale1 value") + bundle2.add_resource(f"{msg_id} = Locale2 value") + + result1, _ = bundle1.format_pattern(msg_id) + result2, _ = bundle2.format_pattern(msg_id) + + event(f"locales={locale1},{locale2}") + assert "Locale1" in result1 + assert "Locale2" in result2 + event("outcome=locale_independence") + + +# ============================================================================ +# ERROR RECOVERY +# ============================================================================ + +class TestErrorRecovery: + """Property tests for error recovery.""" + + @given( + msg_id=ftl_identifiers, + invalid_char=st.sampled_from(["\x00", "\x01", "\x02"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_invalid_syntax_recovers_gracefully( + self, msg_id: str, invalid_char: str + ) -> None: + """PROPERTY: Invalid syntax doesn't crash bundle.""" + bundle = FluentBundle("en") + + # Add invalid FTL + with contextlib.suppress(Exception): + bundle.add_resource(f"{msg_id} = Invalid {invalid_char} text") + + # Bundle should still be usable + bundle.add_resource("valid = Works") + _result, errors = bundle.format_pattern("valid") + event(f"invalid_char={ord(invalid_char)}") + assert errors == () + event("outcome=syntax_error_recovery") + + +# ============================================================================ +# SELECT EXPRESSIONS +# ============================================================================ + +class TestSelectExpressions: + """Property tests for select expression handling.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + count=st.integers(min_value=0, max_value=1000), # Keep practical bound + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_plural_select_expression( + self, msg_id: str, var_name: str, count: int + ) -> None: + """PROPERTY: Plural select expressions work for all counts.""" + bundle = FluentBundle("en") + bundle.add_resource( + f"""{msg_id} = {{ ${var_name} -> + [0] No items + [1] One item + *[other] Many items +}}""" + ) + + result, errors = bundle.format_pattern(msg_id, {var_name: count}) + + event(f"count={count}") + assert errors == () + assert isinstance(result, str) + event("outcome=plural_select_valid") + + @given( + msg_id=ftl_identifiers, + locale=locale_codes, + count=st.integers(min_value=0, max_value=1000), # Keep practical bound + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_locale_specific_plurals( + self, msg_id: str, locale: str, count: int + ) -> None: + """PROPERTY: Locale-specific plurals are handled.""" + bundle = FluentBundle(locale) + bundle.add_resource( + f"""{msg_id} = {{ $count -> + [0] Zero + [1] One + [2] Two + [few] Few + [many] Many + *[other] Other +}}""" + ) + + result, errors = bundle.format_pattern(msg_id, {"count": count}) + + event(f"locale={locale}") + event(f"count={count}") + assert errors == () + assert len(result) > 0 + event("outcome=locale_plurals_valid") + + +# ============================================================================ +# NUMBER FORMATTING VARIATIONS +# ============================================================================ + +class TestNumberFormattingVariations: + """Property tests for number formatting variations.""" + + @given( + msg_id=ftl_identifiers, + number=st.decimals( + min_value=Decimal("0.01"), + max_value=Decimal(1000), + allow_nan=False, + allow_infinity=False, + ), + min_digits=st.integers(min_value=0, max_value=4), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_number_minimum_fraction_digits( + self, msg_id: str, number: Decimal, min_digits: int + ) -> None: + """PROPERTY: minimumFractionDigits option works.""" + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id} = {{ NUMBER($num, minimumFractionDigits: {min_digits}) }}" + ) + + result, errors = bundle.format_pattern(msg_id, {"num": number}) + + event(f"min_digits={min_digits}") + assert errors == () + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + number=st.integers(min_value=0, max_value=1000000), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_number_grouping(self, msg_id: str, number: int) -> None: + """PROPERTY: Number grouping works for large numbers.""" + bundle = FluentBundle("en") + bundle.add_resource( + f'{msg_id} = {{ NUMBER($num, useGrouping: "true") }}' + ) + + result, errors = bundle.format_pattern(msg_id, {"num": number}) + + event(f"number={number}") + assert errors == () + assert isinstance(result, str) + + +# ============================================================================ +# WHITESPACE HANDLING +# ============================================================================ + +class TestWhitespaceHandling: + """Property tests for whitespace handling.""" + + @given( + msg_id=ftl_identifiers, + spaces=st.integers(min_value=0, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_leading_whitespace_in_values( + self, msg_id: str, spaces: int + ) -> None: + """PROPERTY: Leading whitespace in values is preserved.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {' ' * spaces}Value") + + result, errors = bundle.format_pattern(msg_id) + + event(f"spaces={spaces}") + assert errors == () + # Whitespace may be trimmed by parser/formatter + assert "Value" in result + + @given( + msg_id=ftl_identifiers, + text=ftl_safe_text, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_multiline_message_formatting( + self, msg_id: str, text: str + ) -> None: + """PROPERTY: Multiline messages format correctly.""" + assume(len(text) > 0) + assume(text.strip() == text) # No leading/trailing whitespace + assume(not text.startswith((".", "-", "*", "#", "["))) # Exclude FTL syntax + assume(text not in (".", "-", "*", "#", "[", "]")) # Exclude FTL syntax + + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id} =\n" + f" Line 1\n" + f" {text}" + ) + + result, errors = bundle.format_pattern(msg_id) + + event(f"text_len={len(text)}") + assert errors == () + assert text in result + + +# ============================================================================ +# UNICODE EDGE CASES +# ============================================================================ + +class TestUnicodeEdgeCases: + """Property tests for Unicode edge cases.""" + + @given( + msg_id=ftl_identifiers, + emoji=st.sampled_from(["😀", "👋", "🌍", "🎉", "❤️"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_emoji_in_messages(self, msg_id: str, emoji: str) -> None: + """PROPERTY: Emoji characters are handled correctly.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Hello {emoji}") + + result, errors = bundle.format_pattern(msg_id) + + event(f"emoji={emoji}") + assert errors == () + assert emoji in result + event("outcome=emoji_msg_format") + + @given( + msg_id=ftl_identifiers, + rtl_text=st.sampled_from(["مرحبا", "שלום", "مساء"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_rtl_text_handling(self, msg_id: str, rtl_text: str) -> None: + """PROPERTY: RTL text is handled correctly.""" + bundle = FluentBundle("ar") + bundle.add_resource(f"{msg_id} = {rtl_text}") + + result, errors = bundle.format_pattern(msg_id) + + event(f"rtl_text_len={len(rtl_text)}") + assert errors == () + assert rtl_text in result + + @given( + msg_id=ftl_identifiers, + char=st.characters( + min_codepoint=0x1F600, + max_codepoint=0x1F64F, + ), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_unicode_emoji_range(self, msg_id: str, char: str) -> None: + """PROPERTY: Unicode emoji range handled.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Emoji: {char}") + + result, errors = bundle.format_pattern(msg_id) + + event(f"codepoint={ord(char):#06x}") + assert errors == () + assert char in result + + +# ============================================================================ +# PERFORMANCE PROPERTIES +# ============================================================================ + +class TestPerformanceProperties: + """Property tests for performance characteristics.""" + + @given( + msg_count=st.integers(min_value=10, max_value=50), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_large_bundle_performance(self, msg_count: int) -> None: + """PROPERTY: Large bundles perform reasonably.""" + bundle = FluentBundle("en") + + # Add many messages + messages = [f"msg{i} = Value {i}" for i in range(msg_count)] + bundle.add_resource("\n".join(messages)) + + # Format random message should be fast + result, _ = bundle.format_pattern(f"msg{msg_count // 2}") + event(f"msg_count={msg_count}") + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + iterations=st.integers(min_value=1, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_repeated_formatting_consistent( + self, msg_id: str, iterations: int + ) -> None: + """PROPERTY: Repeated formatting gives consistent results.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Consistent value") + + # Format same message multiple times + results = [ + bundle.format_pattern(msg_id)[0] + for _ in range(iterations) + ] + + event(f"iterations={iterations}") + # All results should be identical + assert all(r == results[0] for r in results) + + +# ============================================================================ +# ERROR MESSAGE FORMATTING +# ============================================================================ + +class TestErrorMessageFormatting: + """Property tests for error message formatting.""" + + @given( + msg_id=ftl_identifiers, + unknown_func=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_unknown_function_error( + self, msg_id: str, unknown_func: str + ) -> None: + """PROPERTY: Unknown functions generate errors.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource( + f"{msg_id} = {{ {unknown_func.upper()}($var) }}" + ) + + result, _errors = bundle.format_pattern(msg_id, {"var": 1}) + + # May have errors for unknown function + assert isinstance(result, str) + event(f"unknown_func_len={len(unknown_func)}") + event("outcome=unknown_func_handled") + + @given( + msg_id=ftl_identifiers, + unknown_term=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_unknown_term_error( + self, msg_id: str, unknown_term: str + ) -> None: + """PROPERTY: Unknown terms generate errors.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource(f"{msg_id} = {{ -{unknown_term} }}") + + result, errors = bundle.format_pattern(msg_id) + + # Should have error for unknown term + assert len(errors) > 0 + assert isinstance(result, str) + event(f"unknown_term_len={len(unknown_term)}") + event("outcome=unknown_term_handled") + + +# ============================================================================ +# ARGUMENT TYPE HANDLING +# ============================================================================ + +class TestArgumentTypeHandling: + """Property tests for argument type handling.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + bool_value=st.booleans(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_boolean_argument_handling( + self, msg_id: str, var_name: str, bool_value: bool + ) -> None: + """PROPERTY: Boolean arguments are handled.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {{ ${var_name} }}") + + result, errors = bundle.format_pattern(msg_id, {var_name: bool_value}) + + event(f"bool_val={bool_value}") + assert errors == () + assert isinstance(result, str) + event("outcome=bool_arg_handled") + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + list_value=st.lists(st.integers(), min_size=0, max_size=5), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_list_argument_handling( + self, msg_id: str, var_name: str, list_value: list[int] + ) -> None: + """PROPERTY: List arguments are handled.""" + event(f"list_len={len(list_value)}") + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {{ ${var_name} }}") + + result, _errors = bundle.format_pattern(msg_id, {var_name: list_value}) + + # Lists may not be supported, but shouldn't crash + assert isinstance(result, str) + + +# ============================================================================ +# ATTRIBUTE EDGE CASES +# ============================================================================ + +class TestAttributeEdgeCases: + """Property tests for attribute edge cases.""" + + @given( + msg_id=ftl_identifiers, + attr_name=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_missing_attribute_error( + self, msg_id: str, attr_name: str + ) -> None: + """PROPERTY: Missing attributes generate errors.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource(f"{msg_id} = Value") + + result, errors = bundle.format_pattern(msg_id, attribute=attr_name) + + # Should have error for missing attribute + assert len(errors) > 0 + assert isinstance(result, str) + event(f"missing_attr_len={len(attr_name)}") + event("outcome=missing_attr_handled") + + @given( + msg_id=ftl_identifiers, + attr_name=ftl_identifiers, + var_name=ftl_identifiers, + var_value=st.integers(), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_attribute_with_variables( + self, msg_id: str, attr_name: str, var_name: str, var_value: int + ) -> None: + """PROPERTY: Attributes with variables work.""" + event(f"var_value={var_value}") + bundle = FluentBundle("en") + bundle.add_resource( + f"{msg_id} = Main\n" + f" .{attr_name} = Value: {{ ${var_name} }}" + ) + + result, errors = bundle.format_pattern( + msg_id, + args={var_name: var_value}, + attribute=attr_name, + ) + + assert errors == () + assert str(var_value) in result + + +# ============================================================================ +# ISOLATION MODE +# ============================================================================ diff --git a/tests/test_runtime_bundle_property_state.py b/tests/test_runtime_bundle_property_state.py new file mode 100644 index 00000000..7fda7891 --- /dev/null +++ b/tests/test_runtime_bundle_property_state.py @@ -0,0 +1,652 @@ +"""Hypothesis property-based tests for runtime.bundle: FluentBundle operations.""" + +from __future__ import annotations + +import contextlib +from decimal import Decimal + +from hypothesis import HealthCheck, assume, event, given, settings +from hypothesis import strategies as st + +from ftllexengine import FluentBundle +from ftllexengine.core.locale_utils import normalize_locale +from ftllexengine.diagnostics import ErrorCategory, FrozenFluentError +from tests.strategies import ftl_simple_text + +# ============================================================================ +# HYPOTHESIS STRATEGIES +# ============================================================================ + + +# Strategy for valid FTL identifiers (using st.from_regex per hypothesis.md) +ftl_identifiers = st.from_regex(r"[a-z][a-z0-9_-]*", fullmatch=True) + + +# Strategy for FTL-safe text content (no special characters that break parsing) +ftl_safe_text = st.text( + alphabet=st.characters( + blacklist_categories=("Cc", "Cs"), # Control and surrogate + blacklist_characters="{}[]*$->\n\r", # FTL syntax characters + ), + min_size=0, + max_size=100, +).filter(lambda s: s.strip() == s and len(s.strip()) > 0 if s else True) + + +# Strategy for locale codes +locale_codes = st.sampled_from([ + "en", "en_US", "en_GB", + "lv", "lv_LV", + "de", "de_DE", + "pl", "pl_PL", + "ru", "ru_RU", + "fr", "fr_FR", +]) + +log_source_paths = st.from_regex( + r"[A-Za-z0-9_-][A-Za-z0-9_. /-]{0,31}", + fullmatch=True, +) + + +# ============================================================================ +# PROPERTY TESTS - TERM ATTRIBUTES IN CYCLE DETECTION +# ============================================================================ + + +class TestLocaleFallbackBehavior: + """Property tests for locale fallback behavior.""" + + @given( + locale=locale_codes, + msg_id=ftl_identifiers, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_bundle_respects_locale( + self, locale: str, msg_id: str + ) -> None: + """PROPERTY: Bundle respects specified locale.""" + bundle = FluentBundle(locale) + bundle.add_resource(f"{msg_id} = Value") + + assert bundle.locale == normalize_locale(locale) + + event(f"locale={locale}") + # After formatting, locale should remain + bundle.format_pattern(msg_id) + assert bundle.locale == normalize_locale(locale) + + def test_locale_specific_number_formatting(self) -> None: + """Locale-specific number formatting works.""" + bundle_en = FluentBundle("en_US") + bundle_de = FluentBundle("de_DE") + + ftl = "msg = { NUMBER($num) }" + bundle_en.add_resource(ftl) + bundle_de.add_resource(ftl) + + result_en, _ = bundle_en.format_pattern("msg", {"num": Decimal("1234.56")}) + result_de, _ = bundle_de.format_pattern("msg", {"num": Decimal("1234.56")}) + + # Both should format, potentially differently + assert isinstance(result_en, str) + assert isinstance(result_de, str) + + @given( + locale1=locale_codes, + locale2=locale_codes, + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_locale_isolation_between_bundles( + self, locale1: str, locale2: str + ) -> None: + """PROPERTY: Locales are isolated between bundles.""" + bundle1 = FluentBundle(locale1) + bundle2 = FluentBundle(locale2) + + bundle1.add_resource("msg = Bundle 1") + bundle2.add_resource("msg = Bundle 2") + + event(f"locale1={locale1}") + event(f"locale2={locale2}") + # Locales should remain distinct + assert bundle1.locale == normalize_locale(locale1) + assert bundle2.locale == normalize_locale(locale2) + + +# ============================================================================ +# RESOURCE ORDERING +# ============================================================================ + +class TestResourceOrdering: + """Property tests for resource ordering and priority.""" + + @given( + msg_id=ftl_identifiers, + values=st.lists(ftl_safe_text, min_size=2, max_size=5, unique=True), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_last_resource_wins( + self, msg_id: str, values: list[str] + ) -> None: + """PROPERTY: Last added resource wins for same message ID.""" + assume(all(len(v) > 0 for v in values)) + + bundle = FluentBundle("en") + + # Add same message multiple times with different values + for value in values: + bundle.add_resource(f"{msg_id} = {value}") + + result, _ = bundle.format_pattern(msg_id) + + event(f"override_count={len(values)}") + # Last value should win + assert values[-1] in result + + @given( + msg_count=st.integers(min_value=2, max_value=10), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_resource_accumulation_order(self, msg_count: int) -> None: + """PROPERTY: Resources accumulate in order.""" + bundle = FluentBundle("en") + + # Add messages one by one + for i in range(msg_count): + bundle.add_resource(f"msg{i} = Value {i}") + + # All messages should be accessible + for i in range(msg_count): + result, errors = bundle.format_pattern(f"msg{i}") + assert errors == () + assert f"Value {i}" in result + + event(f"msg_count={msg_count}") + + def test_partial_override_preserves_others(self) -> None: + """Partial resource override preserves other messages.""" + bundle = FluentBundle("en") + + # Add initial messages + bundle.add_resource( + """ +msg1 = Value 1 +msg2 = Value 2 +msg3 = Value 3 +""" + ) + + # Override only msg2 + bundle.add_resource("msg2 = New Value 2") + + # msg1 and msg3 should be unchanged + result1, _ = bundle.format_pattern("msg1") + result2, _ = bundle.format_pattern("msg2") + result3, _ = bundle.format_pattern("msg3") + + assert "Value 1" in result1 + assert "New Value 2" in result2 + assert "Value 3" in result3 + + +# ============================================================================ +# ADDITIONAL ROBUSTNESS TESTS +# ============================================================================ + +class TestAdditionalRobustness: + """Additional property tests for bundle robustness.""" + + @given( + msg_id=ftl_identifiers, + whitespace=st.sampled_from([" ", "\t", " ", "\t\t"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_various_whitespace_types( + self, msg_id: str, whitespace: str + ) -> None: + """PROPERTY: Various whitespace types are handled.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} ={whitespace}Value") + + result, errors = bundle.format_pattern(msg_id) + + event(f"whitespace_repr={whitespace!r}") + assert errors == () + assert "Value" in result + + @given( + msg_id=ftl_identifiers, + special_char=st.sampled_from(["@", "#", "%", "&"]), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_special_characters_in_text( + self, msg_id: str, special_char: str + ) -> None: + """PROPERTY: Special characters in text are preserved.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = Text {special_char} more") + + result, errors = bundle.format_pattern(msg_id) + + event(f"special_char={special_char}") + assert errors == () + assert special_char in result + + def test_empty_message_value(self) -> None: + """Empty message values are handled.""" + bundle = FluentBundle("en", strict=False) + bundle.add_resource("msg = ") + + result, _errors = bundle.format_pattern("msg") + + # Empty value should work + assert isinstance(result, str) + + @given( + msg_id=ftl_identifiers, + number=st.integers(min_value=-2147483648, max_value=2147483647), + ) + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_integer_boundary_values( + self, msg_id: str, number: int + ) -> None: + """PROPERTY: Integer boundary values work.""" + bundle = FluentBundle("en") + bundle.add_resource(f"{msg_id} = {{ ${msg_id} }}") + + result, errors = bundle.format_pattern(msg_id, {msg_id: number}) + + event(f"number={number}") + assert errors == () + assert str(number) in result + + def test_resource_with_only_comments(self) -> None: + """Resource with only comments is handled.""" + bundle = FluentBundle("en") + bundle.add_resource( + """ +# This is a comment +## Another comment +### More comments +""" + ) + + # Should not crash + bundle.add_resource("msg = Works") + _result, errors = bundle.format_pattern("msg") + assert errors == () + + +# ============================================================================ +# ADVANCED BUNDLE PROPERTIES (from test_bundle_advanced_hypothesis.py) +# ============================================================================ + +class TestBundleMessageRegistry: + """Properties about message registration and retrieval.""" + + @given( + locale=locale_codes, + msg_id=ftl_identifiers, + msg_value=ftl_simple_text(), + ) + @settings(max_examples=500) + def test_registered_message_retrievable( + self, locale: str, msg_id: str, msg_value: str + ) -> None: + """Property: Registered messages can be retrieved.""" + event(f"locale={locale}") + bundle = FluentBundle(locale) + + ftl_source = f"{msg_id} = {msg_value}" + bundle.add_resource(ftl_source) + + assert bundle.has_message(msg_id), f"Message {msg_id} not found after registration" + + result, errors = bundle.format_pattern(msg_id) + assert isinstance(result, str), "format_pattern must return string" + assert len(result) > 0, "Formatted message should not be empty" + assert errors == (), f"No errors expected for simple message, got {errors}" + + @given( + msg_id=ftl_identifiers, + ) + @settings(max_examples=300) + def test_unregistered_message_raises_error(self, msg_id: str) -> None: + """Property: Accessing unregistered message raises FrozenFluentError.""" + event(f"msg_id_len={len(msg_id)}") + bundle = FluentBundle("en_US", strict=False) + + nonexistent_id = f"never_registered_{msg_id}" + + result, errors = bundle.format_pattern(nonexistent_id) + assert len(errors) == 1, f"Expected 1 error for nonexistent message, got {len(errors)}" + assert isinstance(errors[0], FrozenFluentError), ( + f"Expected FrozenFluentError, got {type(errors[0])}" + ) + assert errors[0].category == ErrorCategory.REFERENCE + assert result == f"{{{nonexistent_id}}}", f"Expected fallback, got {result}" + + @given( + msg_id=ftl_identifiers, + value1=ftl_simple_text(), + value2=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_message_override_behavior( + self, msg_id: str, value1: str, value2: str + ) -> None: + """Property: Later messages override earlier ones with same ID.""" + values_equal = value1.strip() == value2.strip() + event(f"values_equal={values_equal}") + bundle = FluentBundle("en_US") + + bundle.add_resource(f"{msg_id} = {value1}") + bundle.add_resource(f"{msg_id} = {value2}") + + result, errors = bundle.format_pattern(msg_id) + + assert ( + value2.strip() in result or result.strip() == value2.strip() + ), "Later message should override earlier" + assert errors == (), f"No errors expected for override, got {errors}" + +class TestBundleVariableInterpolation: + """Properties about variable interpolation in messages.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + var_value=st.one_of( + st.text(min_size=1, max_size=50), + st.integers(), + st.decimals(allow_nan=False, allow_infinity=False), + ), + ) + @settings(max_examples=500) + def test_variable_interpolation_preserves_value( + self, msg_id: str, var_name: str, var_value: str | int | Decimal + ) -> None: + """Property: Variable values appear in formatted output.""" + var_type = type(var_value).__name__ + event(f"var_type={var_type}") + bundle = FluentBundle("en_US", use_isolating=False) + + ftl_source = f"{msg_id} = Value: {{ ${var_name} }}" + bundle.add_resource(ftl_source) + + result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) + + assert str(var_value) in result, f"Variable value {var_value} not in result: {result}" + assert errors == (), f"No errors expected for variable interpolation, got {errors}" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + ) + @settings(max_examples=300) + def test_missing_variable_graceful_degradation( + self, msg_id: str, var_name: str + ) -> None: + """Property: Missing variables cause graceful degradation, not crash.""" + event(f"var_name_len={len(var_name)}") + bundle = FluentBundle("en_US", strict=False) + + ftl_source = f"{msg_id} = Value: {{ ${var_name} }}" + bundle.add_resource(ftl_source) + + result, errors = bundle.format_pattern(msg_id, {}) + + assert isinstance(result, str), "Must return string even on error" + error_count = len(errors) + event(f"error_count={error_count}") + assert error_count > 0, "Missing variable should generate error" + + @given( + msg_id=ftl_identifiers, + var_count=st.integers(min_value=1, max_value=10), + ) + @settings(max_examples=200) + def test_multiple_variable_interpolation(self, msg_id: str, var_count: int) -> None: + """Property: Messages with multiple variables interpolate all.""" + bundle = FluentBundle("en_US", use_isolating=False) + + var_names = [f"var{i}" for i in range(var_count)] + placeholders = " ".join(f"{{ ${vn} }}" for vn in var_names) + ftl_source = f"{msg_id} = {placeholders}" + bundle.add_resource(ftl_source) + + args = {vn: str(i) for i, vn in enumerate(var_names)} + result, errors = bundle.format_pattern(msg_id, args) + + event(f"var_count={var_count}") + for value in args.values(): + assert value in result, f"Variable value {value} missing from result" + assert errors == (), f"No errors expected for multiple variables, got {errors}" + +class TestBundleLocaleHandling: + """Properties about locale-specific behavior.""" + + @given( + locale=st.sampled_from(["en_US", "lv_LV", "pl_PL", "de_DE", "fr_FR", "ru_RU"]), + msg_id=ftl_identifiers, + msg_value=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_locale_preserved_in_bundle( + self, locale: str, msg_id: str, msg_value: str + ) -> None: + """Property: Bundle canonicalizes and preserves locale configuration.""" + bundle = FluentBundle(locale) + + assert bundle.locale == normalize_locale(locale), "Bundle locale mismatch" + + ftl_source = f"{msg_id} = {msg_value}" + bundle.add_resource(ftl_source) + + event(f"locale={locale}") + result, errors = bundle.format_pattern(msg_id) + assert isinstance(result, str), "Locale should not affect basic formatting" + assert errors == (), f"No errors expected for simple message, got {errors}" + +class TestBundleIsolatingMarks: + """Properties about Unicode bidi isolation marks.""" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + var_value=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_isolating_marks_with_use_isolating_true( + self, msg_id: str, var_name: str, var_value: str + ) -> None: + """Property: use_isolating=True adds FSI/PDI marks around interpolated values.""" + bundle = FluentBundle("en_US", use_isolating=True) + + ftl_source = f"{msg_id} = {{ ${var_name} }}" + bundle.add_resource(ftl_source) + + result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) + + event("use_isolating=True") + assert "\u2068" in result, "FSI mark missing with use_isolating=True" + assert "\u2069" in result, "PDI mark missing with use_isolating=True" + assert errors == (), f"No errors expected for isolating marks, got {errors}" + + @given( + msg_id=ftl_identifiers, + var_name=ftl_identifiers, + var_value=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_no_isolating_marks_with_use_isolating_false( + self, msg_id: str, var_name: str, var_value: str + ) -> None: + """Property: use_isolating=False omits FSI/PDI marks.""" + bundle = FluentBundle("en_US", use_isolating=False) + + ftl_source = f"{msg_id} = {{ ${var_name} }}" + bundle.add_resource(ftl_source) + + result, errors = bundle.format_pattern(msg_id, {var_name: var_value}) + + event("use_isolating=False") + assert "\u2068" not in result, "FSI mark present with use_isolating=False" + assert "\u2069" not in result, "PDI mark present with use_isolating=False" + assert errors == (), f"No errors expected without isolating marks, got {errors}" + +class TestBundleValidation: + """Properties about resource validation.""" + + @given( + msg_id=ftl_identifiers, + msg_value=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_valid_resource_validation(self, msg_id: str, msg_value: str) -> None: + """Property: Valid FTL passes validation.""" + bundle = FluentBundle("en_US") + + ftl_source = f"{msg_id} = {msg_value}" + result = bundle.validate_resource(ftl_source) + + event(f"id_len={len(msg_id)}") + assert result.is_valid, f"Valid FTL failed validation: {ftl_source}" + assert result.error_count == 0, "Valid FTL should have no errors" + + @given( + invalid_syntax=st.text( + alphabet=st.characters(whitelist_categories=["Cc"]), min_size=1, max_size=50 + ), + ) + @settings(max_examples=200) + def test_invalid_resource_validation(self, invalid_syntax: str) -> None: + """Property: Invalid FTL is detected by validation.""" + bundle = FluentBundle("en_US") + + result = bundle.validate_resource(invalid_syntax) + + event(f"syntax_len={len(invalid_syntax)}") + assert isinstance(result.error_count, int), "error_count must be integer" + +class TestBundleStateConsistency: + """Properties about bundle internal state consistency.""" + + @given( + msg_count=st.integers(min_value=1, max_value=20), + ) + @settings(max_examples=200) + def test_message_count_consistency(self, msg_count: int) -> None: + """Property: get_message_ids returns all registered messages.""" + bundle = FluentBundle("en_US") + + msg_ids = [f"msg{i}" for i in range(msg_count)] + for msg_id in msg_ids: + bundle.add_resource(f"{msg_id} = value") + + retrieved_ids = bundle.get_message_ids() + + event(f"msg_count={msg_count}") + assert len(retrieved_ids) == msg_count, "Message count mismatch" + for msg_id in msg_ids: + assert msg_id in retrieved_ids, f"Message {msg_id} missing from get_message_ids()" + + @given( + msg_id=ftl_identifiers, + msg_value=ftl_simple_text(), + ) + @settings(max_examples=300) + def test_has_message_consistency_with_format( + self, msg_id: str, msg_value: str + ) -> None: + """Property: has_message returns True iff format_pattern succeeds.""" + bundle = FluentBundle("en_US") + + bundle.add_resource(f"{msg_id} = {msg_value}") + + has_msg = bundle.has_message(msg_id) + assert has_msg, f"has_message returned False for registered message {msg_id}" + + event(f"id_len={len(msg_id)}") + result, errors = bundle.format_pattern(msg_id) + assert isinstance(result, str), "format_pattern should succeed when has_message=True" + assert errors == (), f"No errors expected when has_message=True, got {errors}" + +class TestBundleErrorHandling: + """Properties about error handling and recovery.""" + + @given( + invalid_ftl=st.text(min_size=0, max_size=100), + valid_msg=ftl_identifiers.flatmap( + lambda mid: ftl_simple_text().map(lambda val: f"{mid} = {val}") + ), + ) + @settings(max_examples=200) + def test_bundle_continues_after_parse_errors( + self, invalid_ftl: str, valid_msg: str + ) -> None: + """Property: Bundle continues accepting resources after parse errors.""" + bundle = FluentBundle("en_US") + + with contextlib.suppress(Exception): + bundle.add_resource(invalid_ftl) + + bundle.add_resource(valid_msg) + + msg_ids = bundle.get_message_ids() + event(f"msg_count_after_error={len(msg_ids)}") + assert len(msg_ids) > 0, "Bundle should accept valid resources after errors" + + @given( + msg_id=ftl_identifiers, + exception_message=ftl_simple_text(), + ) + @settings(max_examples=200) + def test_format_pattern_never_crashes_application( + self, msg_id: str, exception_message: str + ) -> None: + """Property: format_pattern never raises unexpected exceptions.""" + bundle = FluentBundle("en_US", strict=False) + + def failing_function() -> str: + raise ValueError(exception_message) + + bundle.add_function("FAIL", failing_function) + bundle.add_resource(f"{msg_id} = {{ FAIL() }}") + + result, errors = bundle.format_pattern(msg_id) + + event(f"error_count={len(errors)}") + assert isinstance( + result, str + ), "format_pattern must return string even when function raises" + assert len(errors) > 0, "Function exception should generate error" + +class TestBundleMetamorphicProperties: + """Metamorphic properties: relations between different operations.""" + + @given( + resource_order=st.permutations(list(range(3))), + ) + @settings(max_examples=200) + def test_addition_order_independence_without_conflicts( + self, resource_order: list[int] + ) -> None: + """Property: Adding non-conflicting resources in different orders gives same result.""" + bundle1 = FluentBundle("en_US") + bundle2 = FluentBundle("en_US") + + resources = [f"m{i} = value{i}" for i in range(3)] + + for i in range(3): + bundle1.add_resource(resources[i]) + + for idx in resource_order: + bundle2.add_resource(resources[idx]) + + ids1 = sorted(bundle1.get_message_ids()) + ids2 = sorted(bundle2.get_message_ids()) + + event(f"resource_order={resource_order}") + assert ids1 == ids2, "Resource addition order should not affect final state" diff --git a/tests/test_runtime_functions_builtin_property.py b/tests/test_runtime_functions_builtin_property.py index 866ebdf2..f17c086d 100644 --- a/tests/test_runtime_functions_builtin_property.py +++ b/tests/test_runtime_functions_builtin_property.py @@ -1,11 +1,13 @@ """Property-based tests for NUMBER, DATETIME, and CURRENCY built-in functions. -Unknown but structurally valid locales fall back to en_US formatting. +Public built-in functions reject unknown locales instead of silently +falling back to a different locale. """ from datetime import UTC, datetime from decimal import Decimal +import pytest from hypothesis import event, given from hypothesis import strategies as st @@ -22,13 +24,10 @@ class TestNumberFormatBehavior: """Tests for number_format formatting behavior.""" - def test_number_format_with_invalid_locale_uses_fallback(self) -> None: - """Verify number_format with invalid locale uses en_US fallback.""" - # Invalid locale should still format successfully using en_US fallback - result = number_format(Decimal("1234.5"), "invalid-locale") - # Should contain the number (formatted with en_US rules) - assert "1" in str(result) - assert "234" in str(result) + def test_number_format_with_invalid_locale_raises_value_error(self) -> None: + """Unknown locales are rejected instead of silently using en_US rules.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + number_format(Decimal("1234.5"), "invalid-locale") @given( st.decimals( @@ -45,17 +44,15 @@ def test_number_format_always_returns_fluent_number(self, value: Decimal) -> Non result = number_format(value, "en-US") assert isinstance(result, FluentNumber) - def test_number_format_invalid_locale_with_pattern(self) -> None: - """Verify invalid locale with pattern still formats successfully.""" - result = number_format( - Decimal(42), - "xx-INVALID", - pattern="#,##0.00", - minimum_fraction_digits=2, - ) - # Should return FluentNumber using en_US fallback - assert isinstance(result, FluentNumber) - assert "42" in str(result) + def test_number_format_invalid_locale_with_pattern_raises_value_error(self) -> None: + """Custom patterns do not bypass strict locale validation.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + number_format( + Decimal(42), + "xx-INVALID", + pattern="#,##0.00", + minimum_fraction_digits=2, + ) def test_number_format_success_case_basic(self) -> None: """Verify number_format works correctly in success case.""" @@ -74,21 +71,17 @@ def test_number_format_success_with_grouping(self) -> None: class TestDatetimeFormatBehavior: """Tests for datetime_format formatting behavior.""" - def test_datetime_format_with_invalid_locale_datetime_input(self) -> None: - """Verify datetime_format with invalid locale formats datetime.""" + def test_datetime_format_with_invalid_locale_datetime_input_raises(self) -> None: + """Unknown locales are rejected for datetime inputs.""" dt = datetime(2025, 10, 27, 14, 30, tzinfo=UTC) - # Invalid locale should still format successfully using en_US fallback - result = datetime_format(dt, "invalid-locale") - # Should contain formatted date (using en_US rules) - assert isinstance(result, str) - assert len(str(result)) > 0 + with pytest.raises(ValueError, match="Unknown locale identifier"): + datetime_format(dt, "invalid-locale") - def test_datetime_format_with_invalid_locale_string_input(self) -> None: - """Verify datetime_format with invalid locale handles string input.""" + def test_datetime_format_with_invalid_locale_string_input_raises(self) -> None: + """Unknown locales are rejected for ISO-string inputs too.""" dt_string = "2025-10-27T14:30:00+00:00" - # Invalid locale should still format successfully using en_US fallback - result = datetime_format(dt_string, "bad-locale") - assert isinstance(result, str) + with pytest.raises(ValueError, match="Unknown locale identifier"): + datetime_format(dt_string, "bad-locale") @given(st.datetimes(timezones=st.just(UTC))) def test_datetime_format_always_returns_string(self, dt: datetime) -> None: @@ -97,13 +90,11 @@ def test_datetime_format_always_returns_string(self, dt: datetime) -> None: result = datetime_format(dt, "en-US") assert isinstance(result, str) - def test_datetime_format_invalid_locale_with_pattern(self) -> None: - """Verify invalid locale with pattern still formats successfully.""" + def test_datetime_format_invalid_locale_with_pattern_raises(self) -> None: + """Custom datetime patterns do not bypass strict locale validation.""" dt = datetime(2025, 10, 27, tzinfo=UTC) - result = datetime_format(dt, "invalid", pattern="yyyy-MM-dd") - # Should return formatted string using en_US fallback - assert isinstance(result, str) - assert len(str(result)) > 0 + with pytest.raises(ValueError, match="Unknown locale identifier"): + datetime_format(dt, "invalid", pattern="yyyy-MM-dd") def test_datetime_format_success_case_basic(self) -> None: """Verify datetime_format works correctly in success case.""" @@ -124,13 +115,10 @@ def test_datetime_format_success_with_time_style(self) -> None: class TestCurrencyFormatBehavior: """Tests for currency_format formatting behavior.""" - def test_currency_format_with_invalid_locale(self) -> None: - """Verify currency_format with invalid locale uses fallback.""" - # Invalid locale should still format successfully using en_US fallback - result = currency_format(Decimal("123.45"), "invalid-locale", currency="EUR") - # Should contain currency info (formatted with en_US rules) - assert isinstance(result, FluentNumber) - assert "123" in str(result) or "EUR" in str(result) + def test_currency_format_with_invalid_locale_raises_value_error(self) -> None: + """Unknown locales are rejected for currency formatting.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + currency_format(Decimal("123.45"), "invalid-locale", currency="EUR") @given( st.decimals( @@ -149,17 +137,15 @@ def test_currency_format_always_returns_fluent_number( result = currency_format(value, "en-US", currency=currency) assert isinstance(result, FluentNumber) - def test_currency_format_invalid_locale_with_display_style(self) -> None: - """Verify invalid locale with display style still formats successfully.""" - result = currency_format( - Decimal(100), - "xx-INVALID", - currency="EUR", - currency_display="name", - ) - # Should return FluentNumber using en_US fallback - assert isinstance(result, FluentNumber) - assert "100" in str(result) or "EUR" in str(result) or "euro" in str(result).lower() + def test_currency_format_invalid_locale_with_display_style_raises(self) -> None: + """Display mode options do not bypass strict locale validation.""" + with pytest.raises(ValueError, match="Unknown locale identifier"): + currency_format( + Decimal(100), + "xx-INVALID", + currency="EUR", + currency_display="name", + ) def test_currency_format_success_case_basic(self) -> None: """Verify currency_format works correctly in success case.""" diff --git a/tests/test_runtime_locale_context.py b/tests/test_runtime_locale_context.py index 5ef5a2bf..39bbc6e8 100644 --- a/tests/test_runtime_locale_context.py +++ b/tests/test_runtime_locale_context.py @@ -391,8 +391,9 @@ def mock_import( level: int = 0, ) -> object: if name == "babel": - msg = "Mocked: Babel not installed" - raise ImportError(msg) + err = ModuleNotFoundError("No module named 'babel'") + err.name = "babel" + raise err return original_import( name, globals_dict, @@ -454,8 +455,9 @@ def mock_import( level: int = 0, ) -> object: if name == "babel": - msg = "Mocked: Babel not installed" - raise ImportError(msg) + err = ModuleNotFoundError("No module named 'babel'") + err.name = "babel" + raise err return original_import( name, globals_dict, diff --git a/tests/test_runtime_plural_rules.py b/tests/test_runtime_plural_rules.py index 6f75ca74..9c277510 100644 --- a/tests/test_runtime_plural_rules.py +++ b/tests/test_runtime_plural_rules.py @@ -101,8 +101,9 @@ def mock_import_babel( level: int = 0, ) -> object: if name == "babel" or name.startswith("babel."): - msg = "Mocked: Babel not installed" - raise ImportError(msg) + err = ModuleNotFoundError("No module named 'babel'") + err.name = "babel" + raise err return original_import(name, globals_dict, locals_dict, fromlist, level) with patch("builtins.__import__", side_effect=mock_import_babel): diff --git a/uv.lock b/uv.lock index f7b32f4e..fc80187e 100644 --- a/uv.lock +++ b/uv.lock @@ -301,7 +301,7 @@ wheels = [ [[package]] name = "ftllexengine" -version = "0.163.0" +version = "0.164.0" source = { editable = "." } [package.optional-dependencies]