diff --git a/CHANGELOG.md b/CHANGELOG.md index b48be1c..73cce1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [0.13.0] — 2026-04-20 + +### Added + +**`circuitforge_core.preferences.currency`** — per-user currency code preference + formatting utility (MIT, closes #52) + +- `PREF_CURRENCY_CODE = "currency.code"` — shared store key; all products read from the same path +- `get_currency_code(user_id, store)` — priority fallback: store → `CURRENCY_DEFAULT` env var → `"USD"` +- `set_currency_code(currency_code, user_id, store)` — persists ISO 4217 code, uppercased +- `format_currency(amount, currency_code, locale="en_US")` — uses `babel.numbers.format_currency` when available; falls back to a built-in 30-currency symbol table (no hard babel dependency) +- Symbol table covers: USD, CAD, AUD, NZD, GBP, EUR, CHF, SEK/NOK/DKK, JPY, CNY, KRW, INR, BRL, MXN, ZAR, SGD, HKD, THB, PLN, CZK, HUF, RUB, TRY, ILS, AED, SAR, CLP, COP, ARS, VND, IDR, MYR, PHP +- JPY/KRW/HUF/CLP/COP/VND/IDR format with 0 decimal places per ISO 4217 minor-unit convention +- Exported from `circuitforge_core.preferences` as `currency` submodule +- 30 tests (preference store, env var fallback, format dispatch, symbol table, edge cases) + +--- + ## [0.12.0] — 2026-04-20 ### Added diff --git a/circuitforge_core/__init__.py b/circuitforge_core/__init__.py index 7a3c41f..172b8e1 100644 --- a/circuitforge_core/__init__.py +++ b/circuitforge_core/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.12.0" +__version__ = "0.13.0" try: from circuitforge_core.community import CommunityDB, CommunityPost, SharedStore diff --git a/circuitforge_core/preferences/__init__.py b/circuitforge_core/preferences/__init__.py index 78157ff..fb823e5 100644 --- a/circuitforge_core/preferences/__init__.py +++ b/circuitforge_core/preferences/__init__.py @@ -41,10 +41,12 @@ def set_user_preference( from . import accessibility as accessibility +from . import currency as currency __all__ = [ "get_path", "set_path", "get_user_preference", "set_user_preference", "LocalFileStore", "PreferenceStore", "accessibility", + "currency", ] diff --git a/circuitforge_core/preferences/currency.py b/circuitforge_core/preferences/currency.py new file mode 100644 index 0000000..445e25b --- /dev/null +++ b/circuitforge_core/preferences/currency.py @@ -0,0 +1,148 @@ +# circuitforge_core/preferences/currency.py — currency preference + display formatting +# +# Stores a per-user ISO 4217 currency code and provides format_currency() so every +# product formats prices consistently without rolling its own formatter. +# +# Priority fallback chain for get_currency_code(): +# 1. User preference store ("currency.code") +# 2. CURRENCY_DEFAULT env var +# 3. Hard default: "USD" +# +# format_currency() tries babel for full locale support; falls back to a built-in +# symbol table when babel is not installed (no hard dependency on cf-core). +# +# MIT licensed. +from __future__ import annotations + +import os + +from circuitforge_core.preferences import get_user_preference, set_user_preference + +# ── Preference key constants ────────────────────────────────────────────────── + +PREF_CURRENCY_CODE = "currency.code" +DEFAULT_CURRENCY_CODE = "USD" + +# ── Built-in symbol table (babel fallback) ──────────────────────────────────── +# Covers the currencies most likely to appear across CF product consumers. +# Symbol is prepended; decimal places follow ISO 4217 minor-unit convention. + +_CURRENCY_META: dict[str, tuple[str, int]] = { + # (symbol, decimal_places) + "USD": ("$", 2), + "CAD": ("CA$", 2), + "AUD": ("A$", 2), + "NZD": ("NZ$", 2), + "GBP": ("£", 2), + "EUR": ("€", 2), + "CHF": ("CHF ", 2), + "SEK": ("kr", 2), + "NOK": ("kr", 2), + "DKK": ("kr", 2), + "JPY": ("¥", 0), + "CNY": ("¥", 2), + "KRW": ("₩", 0), + "INR": ("₹", 2), + "BRL": ("R$", 2), + "MXN": ("$", 2), + "ZAR": ("R", 2), + "SGD": ("S$", 2), + "HKD": ("HK$", 2), + "THB": ("฿", 2), + "PLN": ("zł", 2), + "CZK": ("Kč", 2), + "HUF": ("Ft", 0), + "RUB": ("₽", 2), + "TRY": ("₺", 2), + "ILS": ("₪", 2), + "AED": ("د.إ", 2), + "SAR": ("﷼", 2), + "CLP": ("$", 0), + "COP": ("$", 0), + "ARS": ("$", 2), + "VND": ("₫", 0), + "IDR": ("Rp", 0), + "MYR": ("RM", 2), + "PHP": ("₱", 2), +} + +# ── Preference helpers ──────────────────────────────────────────────────────── + + +def get_currency_code( + user_id: str | None = None, + store=None, +) -> str: + """ + Return the user's preferred ISO 4217 currency code. + + Fallback chain: + 1. Value in preference store at "currency.code" + 2. CURRENCY_DEFAULT environment variable + 3. "USD" + """ + stored = get_user_preference(user_id, PREF_CURRENCY_CODE, default=None, store=store) + if stored is not None: + return str(stored).upper() + env_default = os.environ.get("CURRENCY_DEFAULT", "").strip().upper() + if env_default: + return env_default + return DEFAULT_CURRENCY_CODE + + +def set_currency_code( + currency_code: str, + user_id: str | None = None, + store=None, +) -> None: + """Persist *currency_code* (ISO 4217, e.g. 'GBP') to the preference store.""" + set_user_preference(user_id, PREF_CURRENCY_CODE, currency_code.upper(), store=store) + + +# ── Formatting ──────────────────────────────────────────────────────────────── + + +def format_currency( + amount: float, + currency_code: str, + locale: str = "en_US", +) -> str: + """ + Format *amount* as a locale-aware currency string. + + Examples:: + + format_currency(12.5, "GBP") # "£12.50" + format_currency(1234.99, "USD") # "$1,234.99" + format_currency(1500, "JPY") # "¥1,500" + + Uses ``babel.numbers.format_currency`` when babel is installed, which gives + full locale-aware grouping, decimal separators, and symbol placement. + Falls back to a built-in symbol table for the common currencies. + + Args: + amount: Numeric amount to format. + currency_code: ISO 4217 code (e.g. "USD", "GBP", "EUR"). + locale: BCP 47 locale string (e.g. "en_US", "de_DE"). Only used + when babel is available. + + Returns: + Formatted string, e.g. "£12.50". + """ + code = currency_code.upper() + try: + from babel.numbers import format_currency as babel_format # type: ignore[import] + return babel_format(amount, code, locale=locale) + except ImportError: + return _fallback_format(amount, code) + + +def _fallback_format(amount: float, code: str) -> str: + """Format without babel using the built-in symbol table.""" + symbol, decimals = _CURRENCY_META.get(code, (f"{code} ", 2)) + # Group thousands with commas + if decimals == 0: + value_str = f"{int(round(amount)):,}" + else: + value_str = f"{amount:,.{decimals}f}" + return f"{symbol}{value_str}" diff --git a/pyproject.toml b/pyproject.toml index 6a0e52f..d466510 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "circuitforge-core" -version = "0.12.0" +version = "0.13.0" description = "Shared scaffold for CircuitForge products (MIT)" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_preferences.py b/tests/test_preferences.py index ddf5cb5..1c4ae9e 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -233,3 +233,136 @@ class TestPreferenceHelpers: monkeypatch.setattr(store_module, "_DEFAULT_STORE", local) set_user_preference(user_id=None, path="x.y", value=42) assert get_user_preference(user_id=None, path="x.y") == 42 + + +# ── Currency preference tests ───────────────────────────────────────────────── + +from circuitforge_core.preferences.currency import ( + PREF_CURRENCY_CODE, + DEFAULT_CURRENCY_CODE, + get_currency_code, + set_currency_code, + format_currency, + _fallback_format, +) + + +class TestCurrencyPreference: + def _store(self, tmp_path) -> LocalFileStore: + return LocalFileStore(prefs_path=tmp_path / "preferences.yaml") + + def test_default_is_usd(self, tmp_path, monkeypatch): + monkeypatch.delenv("CURRENCY_DEFAULT", raising=False) + store = self._store(tmp_path) + assert get_currency_code(store=store) == "USD" + + def test_set_then_get(self, tmp_path, monkeypatch): + monkeypatch.delenv("CURRENCY_DEFAULT", raising=False) + store = self._store(tmp_path) + set_currency_code("GBP", store=store) + assert get_currency_code(store=store) == "GBP" + + def test_stored_code_uppercased_on_set(self, tmp_path, monkeypatch): + monkeypatch.delenv("CURRENCY_DEFAULT", raising=False) + store = self._store(tmp_path) + set_currency_code("eur", store=store) + assert get_currency_code(store=store) == "EUR" + + def test_env_var_fallback(self, tmp_path, monkeypatch): + monkeypatch.setenv("CURRENCY_DEFAULT", "CAD") + store = self._store(tmp_path) + # No stored preference — env var kicks in + assert get_currency_code(store=store) == "CAD" + + def test_stored_preference_beats_env_var(self, tmp_path, monkeypatch): + monkeypatch.setenv("CURRENCY_DEFAULT", "CAD") + store = self._store(tmp_path) + set_currency_code("AUD", store=store) + assert get_currency_code(store=store) == "AUD" + + def test_user_id_threaded_through(self, tmp_path, monkeypatch): + monkeypatch.delenv("CURRENCY_DEFAULT", raising=False) + store = self._store(tmp_path) + set_currency_code("JPY", user_id="u42", store=store) + assert get_currency_code(user_id="u42", store=store) == "JPY" + + def test_pref_key_constant(self): + assert PREF_CURRENCY_CODE == "currency.code" + + def test_default_constant(self): + assert DEFAULT_CURRENCY_CODE == "USD" + + def test_currency_exported_from_package(self): + from circuitforge_core.preferences import currency + assert hasattr(currency, "get_currency_code") + assert hasattr(currency, "set_currency_code") + assert hasattr(currency, "format_currency") + assert hasattr(currency, "PREF_CURRENCY_CODE") + + +class TestFallbackFormat: + """Tests for _fallback_format — the no-babel code path.""" + + def test_usd_basic(self): + assert _fallback_format(12.5, "USD") == "$12.50" + + def test_gbp_symbol(self): + assert _fallback_format(12.5, "GBP") == "£12.50" + + def test_eur_symbol(self): + assert _fallback_format(12.5, "EUR") == "€12.50" + + def test_jpy_no_decimals(self): + assert _fallback_format(1500, "JPY") == "¥1,500" + + def test_krw_no_decimals(self): + assert _fallback_format(10000, "KRW") == "₩10,000" + + def test_thousands_separator(self): + result = _fallback_format(1234567.89, "USD") + assert result == "$1,234,567.89" + + def test_zero_amount(self): + assert _fallback_format(0, "USD") == "$0.00" + + def test_unknown_currency_uses_code_prefix(self): + result = _fallback_format(10.5, "XYZ") + assert "XYZ" in result + assert "10.50" in result + + def test_cad_symbol(self): + assert _fallback_format(99.99, "CAD") == "CA$99.99" + + def test_inr_symbol(self): + assert _fallback_format(500.0, "INR") == "₹500.00" + + +class TestFormatCurrency: + """Integration tests for format_currency() — exercises the full dispatch.""" + + def test_returns_string(self): + result = format_currency(12.5, "USD") + assert isinstance(result, str) + + def test_contains_amount(self): + result = format_currency(12.5, "GBP") + assert "12" in result + + def test_usd_includes_dollar(self): + result = format_currency(10.0, "USD") + assert "$" in result or "USD" in result + + def test_gbp_includes_pound(self): + result = format_currency(10.0, "GBP") + # babel gives "£10.00", fallback gives "£10.00" — both contain £ + assert "£" in result or "GBP" in result + + def test_jpy_no_cents(self): + # JPY has 0 decimal places — both babel and fallback should omit .00 + result = format_currency(1000, "JPY") + assert ".00" not in result + + def test_currency_code_case_insensitive(self): + upper = format_currency(10.0, "USD") + lower = format_currency(10.0, "usd") + assert upper == lower