feat: add currency_code preference + format_currency utility (closes #52)
Some checks are pending
CI / test (push) Waiting to run
Mirror / mirror (push) Waiting to run

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:
pyr0ball 2026-04-20 13:06:04 -07:00
parent aa057b20e2
commit f9b9fa5283
6 changed files with 302 additions and 2 deletions

View file

@ -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 ## [0.12.0] — 2026-04-20
### Added ### Added

View file

@ -1,4 +1,4 @@
__version__ = "0.12.0" __version__ = "0.13.0"
try: try:
from circuitforge_core.community import CommunityDB, CommunityPost, SharedStore from circuitforge_core.community import CommunityDB, CommunityPost, SharedStore

View file

@ -41,10 +41,12 @@ def set_user_preference(
from . import accessibility as accessibility from . import accessibility as accessibility
from . import currency as currency
__all__ = [ __all__ = [
"get_path", "set_path", "get_path", "set_path",
"get_user_preference", "set_user_preference", "get_user_preference", "set_user_preference",
"LocalFileStore", "PreferenceStore", "LocalFileStore", "PreferenceStore",
"accessibility", "accessibility",
"currency",
] ]

View 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": ("", 2),
"CZK": ("", 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}"

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "circuitforge-core" name = "circuitforge-core"
version = "0.12.0" version = "0.13.0"
description = "Shared scaffold for CircuitForge products (MIT)" description = "Shared scaffold for CircuitForge products (MIT)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View file

@ -233,3 +233,136 @@ class TestPreferenceHelpers:
monkeypatch.setattr(store_module, "_DEFAULT_STORE", local) monkeypatch.setattr(store_module, "_DEFAULT_STORE", local)
set_user_preference(user_id=None, path="x.y", value=42) set_user_preference(user_id=None, path="x.y", value=42)
assert get_user_preference(user_id=None, path="x.y") == 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