feat: add currency_code preference + format_currency utility (closes #52)
Adds circuitforge_core.preferences.currency with get/set_currency_code() and format_currency(). Priority chain: store → CURRENCY_DEFAULT env → USD. Formatting uses babel when available; falls back to a 30-currency symbol table with correct ISO 4217 minor-unit decimal places (0 for JPY, KRW, etc.). Consumed by Snipe, Kiwi, Peregrine, Crossbill. Bumps to v0.13.0.
This commit is contained in:
parent
aa057b20e2
commit
f9b9fa5283
6 changed files with 302 additions and 2 deletions
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
__version__ = "0.12.0"
|
||||
__version__ = "0.13.0"
|
||||
|
||||
try:
|
||||
from circuitforge_core.community import CommunityDB, CommunityPost, SharedStore
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
148
circuitforge_core/preferences/currency.py
Normal file
148
circuitforge_core/preferences/currency.py
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue