From 0419ec09642fe2cf8d715976e6f3d438bfb467f0 Mon Sep 17 00:00:00 2001
From: Ervins Strauhmanis <17160191+resoltico@users.noreply.github.com>
Date: Thu, 23 Apr 2026 03:44:03 +0300
Subject: [PATCH 1/4] release: bootstrap 0.164.0 payload
---
CHANGELOG.md | 73 +-
CONTRIBUTING.md | 7 +-
README.md | 51 +-
docs/CUSTOM_FUNCTIONS_GUIDE.md | 5 +-
docs/DATA_INTEGRITY_ARCHITECTURE.md | 6 +-
docs/DOC_00_Index.md | 3 +-
docs/DOC_01_Core.md | 17 +-
docs/DOC_04_Runtime.md | 39 +-
docs/DOC_04_RuntimeUtilities.md | 5 +-
docs/DOC_05_Diagnostics.md | 27 +-
docs/DOC_05_Errors.md | 2 +
docs/DOC_06_Testing.md | 8 +-
docs/FUZZING_GUIDE.md | 5 +-
docs/FUZZING_GUIDE_ATHERIS.md | 5 +-
docs/LOCALE_GUIDE.md | 21 +-
docs/MIGRATION.md | 4 +-
docs/PARSING_GUIDE.md | 4 +-
docs/QUICK_REFERENCE.md | 9 +-
docs/RELEASE_PROTOCOL.md | 11 +-
docs/TERMINOLOGY.md | 5 +-
docs/THREAD_SAFETY.md | 4 +-
docs/VALIDATION_GUIDE.md | 6 +-
examples/README.md | 6 +-
examples/ftl_linter.py | 143 +-
examples/ftl_transform.py | 4 +-
examples/parser_only.py | 62 +-
fuzz_atheris/README.md | 4 +-
fuzz_atheris/fuzz_iso.py | 13 +-
scripts/run_examples.py | 79 +-
src/ftllexengine/__init__.py | 264 +-
src/ftllexengine/__init__.pyi | 25 +-
src/ftllexengine/_optional_exports.py | 134 +
src/ftllexengine/cache_management.py | 145 +
src/ftllexengine/core/babel_compat.py | 14 +-
src/ftllexengine/diagnostics/__init__.py | 2 +
src/ftllexengine/diagnostics/validation.py | 3 +-
src/ftllexengine/introspection/iso.py | 652 +---
src/ftllexengine/introspection/iso_cache.py | 28 +
src/ftllexengine/introspection/iso_lookup.py | 186 +
.../introspection/iso_validation.py | 76 +
src/ftllexengine/localization/__init__.py | 83 +-
src/ftllexengine/localization/boot.py | 3 +-
src/ftllexengine/localization/orchestrator.py | 41 +-
src/ftllexengine/runtime/__init__.py | 62 +-
src/ftllexengine/runtime/bundle.py | 13 +-
src/ftllexengine/runtime/functions.py | 30 +-
tests/test_architecture_contract.py | 24 +-
tests/test_diagnostics_validation.py | 27 +
tests/test_documentation_tooling.py | 33 +
tests/test_init_module.py | 297 +-
tests/test_introspection_iso.py | 2 +-
tests/test_localization.py | 5 +
tests/test_localization_validation.py | 32 +-
.../test_parsing_babel_compat_unavailable.py | 74 +-
tests/test_parsing_babel_unavailable.py | 59 +-
.../test_regression_depth_memory_security.py | 22 +-
tests/test_runtime_bundle.py | 25 +-
tests/test_runtime_bundle_delegation.py | 10 +-
tests/test_runtime_bundle_locale_date.py | 35 +-
tests/test_runtime_bundle_mutation.py | 8 +-
tests/test_runtime_bundle_property.py | 3049 -----------------
.../test_runtime_bundle_property_advanced.py | 936 +++++
tests/test_runtime_bundle_property_core.py | 739 ++++
...test_runtime_bundle_property_references.py | 835 +++++
tests/test_runtime_bundle_property_state.py | 652 ++++
...test_runtime_functions_builtin_property.py | 96 +-
tests/test_runtime_locale_context.py | 10 +-
tests/test_runtime_plural_rules.py | 5 +-
68 files changed, 4957 insertions(+), 4407 deletions(-)
create mode 100644 src/ftllexengine/_optional_exports.py
create mode 100644 src/ftllexengine/cache_management.py
create mode 100644 src/ftllexengine/introspection/iso_cache.py
create mode 100644 src/ftllexengine/introspection/iso_lookup.py
create mode 100644 src/ftllexengine/introspection/iso_validation.py
delete mode 100644 tests/test_runtime_bundle_property.py
create mode 100644 tests/test_runtime_bundle_property_advanced.py
create mode 100644 tests/test_runtime_bundle_property_core.py
create mode 100644 tests/test_runtime_bundle_property_references.py
create mode 100644 tests/test_runtime_bundle_property_state.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 179d9aba..5cfc13f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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?"]
@@ -22,6 +22,77 @@ 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.
+- **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, 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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da2a5494..13cd9ae8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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/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..5f651270 100644
--- a/docs/CUSTOM_FUNCTIONS_GUIDE.md
+++ b/docs/CUSTOM_FUNCTIONS_GUIDE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..d6bdb11a 100644
--- a/docs/DATA_INTEGRITY_ARCHITECTURE.md
+++ b/docs/DATA_INTEGRITY_ARCHITECTURE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..87963e18 100644
--- a/docs/DOC_00_Index.md
+++ b/docs/DOC_00_Index.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..412ebe02 100644
--- a/docs/DOC_01_Core.md
+++ b/docs/DOC_01_Core.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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_04_Runtime.md b/docs/DOC_04_Runtime.md
index 1b9d344f..a7df8580 100644
--- a/docs/DOC_04_Runtime.md
+++ b/docs/DOC_04_Runtime.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..9fbbbc3b 100644
--- a/docs/DOC_04_RuntimeUtilities.md
+++ b/docs/DOC_04_RuntimeUtilities.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..0d0a2bed 100644
--- a/docs/DOC_05_Diagnostics.md
+++ b/docs/DOC_05_Diagnostics.md
@@ -2,9 +2,9 @@
afad: "3.5"
version: "0.163.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..bbdf7777 100644
--- a/docs/DOC_05_Errors.md
+++ b/docs/DOC_05_Errors.md
@@ -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..ba02543a 100644
--- a/docs/DOC_06_Testing.md
+++ b/docs/DOC_06_Testing.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..30cb0592 100644
--- a/docs/FUZZING_GUIDE.md
+++ b/docs/FUZZING_GUIDE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..8c122608 100644
--- a/docs/FUZZING_GUIDE_ATHERIS.md
+++ b/docs/FUZZING_GUIDE_ATHERIS.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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/LOCALE_GUIDE.md b/docs/LOCALE_GUIDE.md
index 3741e2ef..36f8b5bd 100644
--- a/docs/LOCALE_GUIDE.md
+++ b/docs/LOCALE_GUIDE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..4f5f2bb0 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..c1fb7787 100644
--- a/docs/PARSING_GUIDE.md
+++ b/docs/PARSING_GUIDE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..03093dde 100644
--- a/docs/QUICK_REFERENCE.md
+++ b/docs/QUICK_REFERENCE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..28c855b7 100644
--- a/docs/RELEASE_PROTOCOL.md
+++ b/docs/RELEASE_PROTOCOL.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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?"]
@@ -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,7 +91,7 @@ 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"
```
@@ -121,7 +124,7 @@ Do not cut the release branch or tag anything while any gate is red.
Create the release branch and treat staging as a scope-verification checkpoint:
```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
diff --git a/docs/TERMINOLOGY.md b/docs/TERMINOLOGY.md
index 6bea954e..b2ec58ee 100644
--- a/docs/TERMINOLOGY.md
+++ b/docs/TERMINOLOGY.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..c76c6fee 100644
--- a/docs/THREAD_SAFETY.md
+++ b/docs/THREAD_SAFETY.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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/VALIDATION_GUIDE.md b/docs/VALIDATION_GUIDE.md
index 9d139238..2b29b0bd 100644
--- a/docs/VALIDATION_GUIDE.md
+++ b/docs/VALIDATION_GUIDE.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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..6ba9a780 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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/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..1da739c2 100644
--- a/fuzz_atheris/README.md
+++ b/fuzz_atheris/README.md
@@ -2,7 +2,7 @@
afad: "3.5"
version: "0.163.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/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):
From 5b6b872b5e6021692893d8d2f08bfbd6891eb9a4 Mon Sep 17 00:00:00 2001
From: Ervins Strauhmanis <17160191+resoltico@users.noreply.github.com>
Date: Thu, 23 Apr 2026 04:06:30 +0300
Subject: [PATCH 2/4] release: bump version to 0.164.0
---
CHANGELOG.md | 6 ++++--
CONTRIBUTING.md | 2 +-
PATENTS.md | 2 +-
docs/CUSTOM_FUNCTIONS_GUIDE.md | 2 +-
docs/DATA_INTEGRITY_ARCHITECTURE.md | 2 +-
docs/DOC_00_Index.md | 2 +-
docs/DOC_01_Core.md | 2 +-
docs/DOC_02_SyntaxExpressions.md | 2 +-
docs/DOC_02_SyntaxTypes.md | 2 +-
docs/DOC_02_Types.md | 2 +-
docs/DOC_03_LocaleParsing.md | 2 +-
docs/DOC_03_Parsing.md | 2 +-
docs/DOC_04_Introspection.md | 2 +-
docs/DOC_04_Runtime.md | 2 +-
docs/DOC_04_RuntimeUtilities.md | 2 +-
docs/DOC_05_Diagnostics.md | 2 +-
docs/DOC_05_Errors.md | 2 +-
docs/DOC_06_Testing.md | 2 +-
docs/FUZZING_GUIDE.md | 2 +-
docs/FUZZING_GUIDE_ATHERIS.md | 2 +-
docs/FUZZING_GUIDE_HYPOFUZZ.md | 2 +-
docs/LOCALE_GUIDE.md | 2 +-
docs/MIGRATION.md | 2 +-
docs/PARSING_GUIDE.md | 2 +-
docs/QUICK_REFERENCE.md | 2 +-
docs/RELEASE_PROTOCOL.md | 11 +++++++++--
docs/TERMINOLOGY.md | 2 +-
docs/THREAD_SAFETY.md | 2 +-
docs/TYPE_HINTS_GUIDE.md | 2 +-
docs/VALIDATION_GUIDE.md | 2 +-
examples/README.md | 2 +-
examples/README_TYPE_CHECKING.md | 2 +-
fuzz_atheris/README.md | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
35 files changed, 46 insertions(+), 37 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5cfc13f3..bcbaae3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: CHANGELOG
updated: "2026-04-23"
route:
@@ -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
@@ -6968,6 +6969,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 13cd9ae8..b410e7e2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: CONTRIBUTING
updated: "2026-04-23"
route:
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/docs/CUSTOM_FUNCTIONS_GUIDE.md b/docs/CUSTOM_FUNCTIONS_GUIDE.md
index 5f651270..66952662 100644
--- a/docs/CUSTOM_FUNCTIONS_GUIDE.md
+++ b/docs/CUSTOM_FUNCTIONS_GUIDE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: CUSTOM_FUNCTIONS
updated: "2026-04-23"
route:
diff --git a/docs/DATA_INTEGRITY_ARCHITECTURE.md b/docs/DATA_INTEGRITY_ARCHITECTURE.md
index d6bdb11a..e164cfff 100644
--- a/docs/DATA_INTEGRITY_ARCHITECTURE.md
+++ b/docs/DATA_INTEGRITY_ARCHITECTURE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: ARCHITECTURE
updated: "2026-04-23"
route:
diff --git a/docs/DOC_00_Index.md b/docs/DOC_00_Index.md
index 87963e18..824002b9 100644
--- a/docs/DOC_00_Index.md
+++ b/docs/DOC_00_Index.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: INDEX
updated: "2026-04-23"
route:
diff --git a/docs/DOC_01_Core.md b/docs/DOC_01_Core.md
index 412ebe02..4cb56584 100644
--- a/docs/DOC_01_Core.md
+++ b/docs/DOC_01_Core.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: CORE
updated: "2026-04-23"
route:
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 a7df8580..fe40c7fe 100644
--- a/docs/DOC_04_Runtime.md
+++ b/docs/DOC_04_Runtime.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: RUNTIME
updated: "2026-04-23"
route:
diff --git a/docs/DOC_04_RuntimeUtilities.md b/docs/DOC_04_RuntimeUtilities.md
index 9fbbbc3b..402fcbeb 100644
--- a/docs/DOC_04_RuntimeUtilities.md
+++ b/docs/DOC_04_RuntimeUtilities.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: RUNTIME_UTILITIES
updated: "2026-04-23"
route:
diff --git a/docs/DOC_05_Diagnostics.md b/docs/DOC_05_Diagnostics.md
index 0d0a2bed..e7ed272c 100644
--- a/docs/DOC_05_Diagnostics.md
+++ b/docs/DOC_05_Diagnostics.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: DIAGNOSTICS
updated: "2026-04-23"
route:
diff --git a/docs/DOC_05_Errors.md b/docs/DOC_05_Errors.md
index bbdf7777..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:
diff --git a/docs/DOC_06_Testing.md b/docs/DOC_06_Testing.md
index ba02543a..c95d895f 100644
--- a/docs/DOC_06_Testing.md
+++ b/docs/DOC_06_Testing.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: TESTING
updated: "2026-04-23"
route:
diff --git a/docs/FUZZING_GUIDE.md b/docs/FUZZING_GUIDE.md
index 30cb0592..42e02d5d 100644
--- a/docs/FUZZING_GUIDE.md
+++ b/docs/FUZZING_GUIDE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: FUZZING
updated: "2026-04-23"
route:
diff --git a/docs/FUZZING_GUIDE_ATHERIS.md b/docs/FUZZING_GUIDE_ATHERIS.md
index 8c122608..8bf7b1b6 100644
--- a/docs/FUZZING_GUIDE_ATHERIS.md
+++ b/docs/FUZZING_GUIDE_ATHERIS.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: FUZZING
updated: "2026-04-23"
route:
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 36f8b5bd..a1c32520 100644
--- a/docs/LOCALE_GUIDE.md
+++ b/docs/LOCALE_GUIDE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: LOCALE
updated: "2026-04-23"
route:
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 4f5f2bb0..df2f3fc8 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: MIGRATION
updated: "2026-04-23"
route:
diff --git a/docs/PARSING_GUIDE.md b/docs/PARSING_GUIDE.md
index c1fb7787..ee560114 100644
--- a/docs/PARSING_GUIDE.md
+++ b/docs/PARSING_GUIDE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: PARSING
updated: "2026-04-23"
route:
diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md
index 03093dde..fca8de48 100644
--- a/docs/QUICK_REFERENCE.md
+++ b/docs/QUICK_REFERENCE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: REFERENCE
updated: "2026-04-23"
route:
diff --git a/docs/RELEASE_PROTOCOL.md b/docs/RELEASE_PROTOCOL.md
index 28c855b7..328a7097 100644
--- a/docs/RELEASE_PROTOCOL.md
+++ b/docs/RELEASE_PROTOCOL.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: RELEASE
updated: "2026-04-23"
route:
@@ -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
@@ -95,6 +95,11 @@ 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:
@@ -115,6 +120,8 @@ 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.
diff --git a/docs/TERMINOLOGY.md b/docs/TERMINOLOGY.md
index b2ec58ee..3342a4fa 100644
--- a/docs/TERMINOLOGY.md
+++ b/docs/TERMINOLOGY.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: TERMINOLOGY
updated: "2026-04-23"
route:
diff --git a/docs/THREAD_SAFETY.md b/docs/THREAD_SAFETY.md
index c76c6fee..536063ce 100644
--- a/docs/THREAD_SAFETY.md
+++ b/docs/THREAD_SAFETY.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: ARCHITECTURE
updated: "2026-04-23"
route:
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 2b29b0bd..f0549226 100644
--- a/docs/VALIDATION_GUIDE.md
+++ b/docs/VALIDATION_GUIDE.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: VALIDATION
updated: "2026-04-23"
route:
diff --git a/examples/README.md b/examples/README.md
index 6ba9a780..e05690c1 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: EXAMPLES
updated: "2026-04-23"
route:
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/fuzz_atheris/README.md b/fuzz_atheris/README.md
index 1da739c2..35feb829 100644
--- a/fuzz_atheris/README.md
+++ b/fuzz_atheris/README.md
@@ -1,6 +1,6 @@
---
afad: "3.5"
-version: "0.163.0"
+version: "0.164.0"
domain: FUZZING
updated: "2026-04-23"
route:
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/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]
From 21a5fb6d79998846f5aa2bcfc22a45ca8772ecb9 Mon Sep 17 00:00:00 2001
From: Ervins Strauhmanis <17160191+resoltico@users.noreply.github.com>
Date: Thu, 23 Apr 2026 04:08:16 +0300
Subject: [PATCH 3/4] docs: clarify bootstrap release scope checks
---
CHANGELOG.md | 7 ++++---
docs/RELEASE_PROTOCOL.md | 10 ++++++++--
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcbaae3c..885cd59b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,9 +73,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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, and the Atheris docs now
- state clearly that `--list` inspects stored crashes and findings instead of
- enumerating target names.
+ 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`
diff --git a/docs/RELEASE_PROTOCOL.md b/docs/RELEASE_PROTOCOL.md
index 328a7097..2b863a8f 100644
--- a/docs/RELEASE_PROTOCOL.md
+++ b/docs/RELEASE_PROTOCOL.md
@@ -128,7 +128,8 @@ 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 switch -c release/X.Y.Z
@@ -145,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.
@@ -170,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:
From 0fee9ba784b8fbff1e6220fb7e84ec723b908ab5 Mon Sep 17 00:00:00 2001
From: Ervins Strauhmanis <17160191+resoltico@users.noreply.github.com>
Date: Thu, 23 Apr 2026 04:21:14 +0300
Subject: [PATCH 4/4] ci: upgrade workflows to node24 actions
---
.github/workflows/publish.yml | 19 +++++++++++--------
.github/workflows/test.yml | 5 ++++-
CHANGELOG.md | 6 ++++++
3 files changed, 21 insertions(+), 9 deletions(-)
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 885cd59b..ef94d88d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,12 @@ 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`,