drop DRF and django-filter to make django_nepkit framework‑agnostic, simplify dependencies, and align with v0.2.2 breaking change.
528 lines
18 KiB
Python
528 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import time as python_time
|
|
from decimal import Decimal, InvalidOperation
|
|
from functools import lru_cache
|
|
from typing import Any, Optional
|
|
|
|
from nepali.datetime import nepalidate, nepalidatetime
|
|
from nepali.exceptions import FormatNotMatchException
|
|
from nepali.locations import districts, municipalities, provinces
|
|
|
|
from django_nepkit.conf import nepkit_settings
|
|
from django_nepkit.constants import NEGATIVE_PREFIX, NEPALI_ONES, NEPALI_UNITS
|
|
|
|
|
|
# Anything we treat as "this format didn't match, try the next one".
|
|
_PARSE_ERRORS = (ValueError, TypeError, FormatNotMatchException)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live format constants
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# ``BS_DATE_FORMAT`` / ``BS_DATETIME_FORMAT`` / ``BS_TIME_FORMAT`` are exposed
|
|
# for backwards compat (e.g. ``from django_nepkit.utils import BS_DATE_FORMAT``).
|
|
# Re-exporting them through a module-level ``__getattr__`` means each
|
|
# attribute access re-reads ``nepkit_settings`` rather than being frozen at
|
|
# import time.
|
|
#
|
|
# If the user changes ``settings.NEPKIT['BS_DATE_FORMAT']`` via
|
|
# ``override_settings`` (or a runtime signal), a fresh
|
|
# ``from django_nepkit.utils import BS_DATE_FORMAT`` will see the new value.
|
|
|
|
__all__ = [
|
|
"BS_DATE_FORMAT", # noqa: F822 — exposed via module __getattr__
|
|
"BS_DATETIME_FORMAT", # noqa: F822
|
|
"BS_TIME_FORMAT", # noqa: F822
|
|
"format_nepali_currency",
|
|
"format_nepali_date",
|
|
"format_nepali_datetime",
|
|
"format_nepali_time",
|
|
"try_parse_nepali_date",
|
|
"try_parse_nepali_datetime",
|
|
"try_parse_nepali_time",
|
|
"get_districts_by_province",
|
|
"get_municipalities_by_district",
|
|
"number_to_nepali_words",
|
|
"english_to_nepali_unicode",
|
|
"normalize_address",
|
|
]
|
|
|
|
|
|
_LIVE_SETTINGS = {
|
|
"BS_DATE_FORMAT",
|
|
"BS_DATETIME_FORMAT",
|
|
"BS_TIME_FORMAT",
|
|
}
|
|
|
|
|
|
def __getattr__(name):
|
|
if name in _LIVE_SETTINGS:
|
|
return getattr(nepkit_settings, name)
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _try_parse_nepali(value: Any, cls: Any, fallback_fmt: str) -> Any:
|
|
"""Turn a string into a Nepali date/datetime object.
|
|
|
|
Returns ``None`` for empty input or when no format matches. Other
|
|
exceptions (programmer errors, OOM) propagate.
|
|
"""
|
|
if value in (None, ""):
|
|
return None
|
|
if isinstance(value, cls):
|
|
return value
|
|
if isinstance(value, str):
|
|
formats = list(nepkit_settings.DATE_INPUT_FORMATS)
|
|
if fallback_fmt not in formats:
|
|
formats.append(fallback_fmt)
|
|
for fmt in formats:
|
|
try:
|
|
return cls.strptime(value.strip(), fmt)
|
|
except _PARSE_ERRORS:
|
|
continue
|
|
return None
|
|
|
|
|
|
def try_parse_nepali_date(value: Any) -> Optional[nepalidate]:
|
|
"""Convert any value to a Nepali Date (``None`` on failure)."""
|
|
return _try_parse_nepali(value, nepalidate, nepkit_settings.BS_DATE_FORMAT)
|
|
|
|
|
|
def try_parse_nepali_datetime(value: Any) -> Optional[nepalidatetime]:
|
|
"""Convert any value to a Nepali DateTime (``None`` on failure)."""
|
|
return _try_parse_nepali(value, nepalidatetime, nepkit_settings.BS_DATETIME_FORMAT)
|
|
|
|
|
|
def try_parse_nepali_time(value: Any) -> Optional[python_time]:
|
|
"""Convert any value to a Python ``time`` (``None`` on failure)."""
|
|
from datetime import datetime as _dt
|
|
|
|
if value in (None, ""):
|
|
return None
|
|
if isinstance(value, python_time):
|
|
return value
|
|
if isinstance(value, _dt):
|
|
return value.time()
|
|
if isinstance(value, str):
|
|
formats = list(nepkit_settings.TIME_INPUT_FORMATS)
|
|
if nepkit_settings.BS_TIME_FORMAT not in formats:
|
|
formats.append(nepkit_settings.BS_TIME_FORMAT)
|
|
for fmt in formats:
|
|
try:
|
|
return _dt.strptime(value.strip(), fmt).time()
|
|
except _PARSE_ERRORS:
|
|
continue
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def format_nepali_time(value: Any, format_str: Optional[str] = None) -> str:
|
|
"""Format a Python ``time``/``datetime`` as a string.
|
|
|
|
Returns an empty string for ``None`` and falls back to ``str(value)`` when
|
|
the value cannot be formatted. Honours ``nepkit_settings.BS_TIME_FORMAT``
|
|
when no explicit format is supplied.
|
|
"""
|
|
from datetime import datetime as _dt
|
|
|
|
if value is None:
|
|
return ""
|
|
if format_str is None:
|
|
format_str = nepkit_settings.BS_TIME_FORMAT
|
|
if isinstance(value, _dt):
|
|
value = value.time()
|
|
if isinstance(value, python_time):
|
|
try:
|
|
return value.strftime(format_str)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
return str(value)
|
|
|
|
|
|
def format_nepali_date(value: Any, format_str: Optional[str] = None) -> str:
|
|
"""Format a ``nepalidate``/``nepalidatetime``/``str`` as a string.
|
|
|
|
Returns an empty string for ``None`` and ``str(value)`` for anything
|
|
that can't be parsed. Honours ``nepkit_settings.BS_DATE_FORMAT``
|
|
when no explicit format is supplied.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if format_str is None:
|
|
format_str = nepkit_settings.BS_DATE_FORMAT
|
|
if isinstance(value, (nepalidate, nepalidatetime)):
|
|
try:
|
|
return value.strftime(format_str)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
if isinstance(value, str):
|
|
# Already-formatted strings pass through unchanged.
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def format_nepali_datetime(value: Any, format_str: Optional[str] = None) -> str:
|
|
"""Format a ``nepalidatetime`` for display.
|
|
|
|
Honours ``nepkit_settings.BS_DATETIME_FORMAT`` when no explicit
|
|
format is supplied.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if format_str is None:
|
|
format_str = nepkit_settings.BS_DATETIME_FORMAT
|
|
if isinstance(value, nepalidatetime):
|
|
try:
|
|
return value.strftime(format_str)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
if isinstance(value, nepalidate):
|
|
# A pure date stored where a datetime is expected — format as a
|
|
# date and append the default time.
|
|
try:
|
|
return value.strftime(
|
|
format_str.split(" ")[0] if " " in format_str else format_str
|
|
)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
if isinstance(value, str):
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def format_nepali_currency(
|
|
number: Any, currency_symbol: str = "Rs.", ne: bool = False
|
|
) -> str:
|
|
"""
|
|
Format a number with Nepali-style commas and optional currency symbol.
|
|
|
|
Examples:
|
|
``1234567`` -> ``Rs. 12,34,567.00``
|
|
``Decimal('123456789012345.67')`` -> preserves precision
|
|
``None`` -> ``""``
|
|
|
|
Raises:
|
|
ValueError: if ``number`` cannot be coerced to a finite ``Decimal``.
|
|
"""
|
|
from nepali.number import add_comma, english_to_nepali
|
|
|
|
if number is None:
|
|
return ""
|
|
if isinstance(number, bool):
|
|
raise ValueError(f"Cannot format boolean as currency: {number!r}")
|
|
|
|
try:
|
|
amount = Decimal(str(number)) if not isinstance(number, Decimal) else number
|
|
except (InvalidOperation, ValueError) as exc:
|
|
raise ValueError(f"Cannot format {number!r} as currency: {exc}") from exc
|
|
|
|
if not amount.is_finite():
|
|
raise ValueError(f"Cannot format non-finite currency value: {number!r}")
|
|
|
|
quantized = amount.quantize(Decimal("0.01"))
|
|
sign = "-" if quantized < 0 else ""
|
|
abs_q = -quantized if quantized < 0 else quantized
|
|
integer_part, _, decimal_part = format(abs_q, "f").partition(".")
|
|
formatted_integer = add_comma(int(integer_part))
|
|
res = (
|
|
f"{formatted_integer}.{decimal_part or '00'}"
|
|
if "." in format(abs_q, "f") or decimal_part
|
|
else f"{formatted_integer}.00"
|
|
)
|
|
|
|
if ne:
|
|
res = english_to_nepali(res)
|
|
|
|
if currency_symbol:
|
|
return f"{currency_symbol} {sign}{res}"
|
|
return f"{sign}{res}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nepali words
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def number_to_nepali_words(number: Any) -> str:
|
|
"""
|
|
Convert a number to Nepali words.
|
|
|
|
Examples:
|
|
``123`` -> ``एक सय तेईस``
|
|
``-25`` -> ``ऋणात्मक पच्चीस``
|
|
|
|
Raises:
|
|
ValueError: if ``number`` cannot be coerced to an int.
|
|
"""
|
|
if number is None:
|
|
return ""
|
|
|
|
try:
|
|
if isinstance(number, bool):
|
|
raise ValueError(f"Cannot convert boolean to words: {number!r}")
|
|
if isinstance(number, Decimal):
|
|
if not number.is_finite():
|
|
raise ValueError(f"Cannot convert non-finite Decimal: {number!r}")
|
|
num = int(number)
|
|
else:
|
|
num = int(Decimal(str(number)).quantize(Decimal("1")))
|
|
except (InvalidOperation, ValueError, TypeError) as exc:
|
|
raise ValueError(f"Cannot convert {number!r} to Nepali words: {exc}") from exc
|
|
|
|
if num == 0:
|
|
return "शून्य"
|
|
|
|
def _convert(n: int) -> str:
|
|
if n == 0:
|
|
return ""
|
|
if n < 0:
|
|
return f"{NEGATIVE_PREFIX} {_convert(-n)}".strip()
|
|
if n < 100:
|
|
return NEPALI_ONES[n]
|
|
for i in range(len(NEPALI_UNITS) - 1, 0, -1):
|
|
div, unit_name = NEPALI_UNITS[i]
|
|
if n >= div:
|
|
prefix_val = n // div
|
|
remainder = n % div
|
|
prefix_words = _convert(prefix_val)
|
|
res = f"{prefix_words} {unit_name}".strip()
|
|
if remainder > 0:
|
|
res = f"{res} {_convert(remainder)}"
|
|
return res.strip()
|
|
return ""
|
|
|
|
return _convert(num)
|
|
|
|
|
|
def english_to_nepali_unicode(text: Any) -> str:
|
|
"""Convert English digits in ``text`` to Nepali Unicode digits."""
|
|
from nepali.number import english_to_nepali
|
|
|
|
if text is None:
|
|
return ""
|
|
return english_to_nepali(text)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Location data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalize_nepali_text(text):
|
|
"""Normalize Nepali text for easier matching.
|
|
|
|
Replaces Chandrabindu with Anusvara so that variant spellings of the
|
|
same place name (e.g. काठमाडौँ vs काठमाडौं) compare equal.
|
|
"""
|
|
if not text:
|
|
return text
|
|
return text.replace("ँ", "ं").replace("ाँ", "ां")
|
|
|
|
|
|
def get_districts_by_province(province_name, ne=False, en=True):
|
|
"""Get all districts for a province."""
|
|
return _get_location_children(provinces, province_name, "districts", ne=ne)
|
|
|
|
|
|
def get_municipalities_by_district(district_name, ne=False, en=True):
|
|
"""Get all municipalities for a district."""
|
|
return _get_location_children(districts, district_name, "municipalities", ne=ne)
|
|
|
|
|
|
def _get_location_children(parent_list, parent_name, child_attr, ne=False):
|
|
selected_parent = None
|
|
for p in parent_list:
|
|
p_name = p.name
|
|
p_name_ne = getattr(p, "name_nepali", None)
|
|
if p_name == parent_name or p_name_ne == parent_name:
|
|
selected_parent = p
|
|
break
|
|
if not selected_parent:
|
|
return []
|
|
children = getattr(selected_parent, child_attr, [])
|
|
if ne:
|
|
return [
|
|
{
|
|
"id": getattr(child, "name_nepali", child.name),
|
|
"text": getattr(child, "name_nepali", child.name),
|
|
}
|
|
for child in children
|
|
]
|
|
return [{"id": child.name, "text": child.name} for child in children]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Address normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Rank higher = better match. Used to pick the best candidate when a token
|
|
# matches multiple location names. Exact > substring so that the most
|
|
# precise match always wins.
|
|
_MATCH_RANK_EXACT = 100
|
|
_MATCH_RANK_SUBSTRING = 20
|
|
|
|
# Minimum lengths for substring matches (avoid matching single characters).
|
|
_MIN_EN_SUBSTRING = 4
|
|
_MIN_NE_SUBSTRING = 2
|
|
|
|
|
|
def _classify_match(
|
|
token, token_lower, name_eng, name_eng_lower, name_nep, name_nep_norm
|
|
):
|
|
"""Return a (rank, matched_name) tuple or ``None`` if there's no match.
|
|
|
|
The caller is expected to take the highest-ranked candidate.
|
|
"""
|
|
# Nepali exact (case-sensitive, since the script has no case).
|
|
if name_nep and token == name_nep:
|
|
return (_MATCH_RANK_EXACT, name_eng)
|
|
if name_nep and name_nep_norm and token == name_nep_norm:
|
|
return (_MATCH_RANK_EXACT, name_eng)
|
|
# English exact (case-insensitive).
|
|
if name_eng and token_lower == name_eng_lower:
|
|
return (_MATCH_RANK_EXACT, name_eng)
|
|
# English substring (length-guarded to avoid false positives).
|
|
if len(token) >= _MIN_EN_SUBSTRING and name_eng and token_lower in name_eng_lower:
|
|
return (_MATCH_RANK_SUBSTRING, name_eng)
|
|
# Nepali substring
|
|
if len(token) >= _MIN_NE_SUBSTRING and name_nep_norm and token in name_nep_norm:
|
|
return (_MATCH_RANK_SUBSTRING, name_eng)
|
|
return None
|
|
|
|
|
|
def _matches_location_name(name_eng, name_nep, token, normalized_token):
|
|
"""Boolean wrapper around :func:`_classify_match`.
|
|
|
|
Returns ``True`` when ``token`` matches either the English or Nepali
|
|
name according to the same rules used by
|
|
:func:`_find_location_in_tokens`. Kept for backwards compatibility
|
|
with existing test suites.
|
|
"""
|
|
classified = _classify_match(
|
|
token,
|
|
token.lower(),
|
|
name_eng,
|
|
(name_eng or "").lower(),
|
|
name_nep,
|
|
_normalize_nepali_text(name_nep),
|
|
)
|
|
return classified is not None
|
|
|
|
|
|
def _find_location_in_tokens(location_list, tokens, normalized_tokens):
|
|
"""Find the best-matching location for any of ``tokens``.
|
|
|
|
Returns the location object, or ``None`` if nothing matches. When
|
|
multiple candidates match, the one with the highest rank wins; ties
|
|
are broken by the first token that produced the match.
|
|
|
|
``normalized_tokens`` is accepted for API stability but is currently
|
|
unused (ranking is done on the ASCII-folded form of each token).
|
|
"""
|
|
del normalized_tokens # currently unused; kept for API symmetry
|
|
best = None
|
|
best_rank = -1
|
|
for token in tokens:
|
|
for location in location_list:
|
|
name_eng = location.name
|
|
name_eng_lower = name_eng.lower() if name_eng else ""
|
|
name_nep = getattr(location, "name_nepali", None) or ""
|
|
name_nep_norm = _normalize_nepali_text(name_nep)
|
|
classified = _classify_match(
|
|
token, token.lower(), name_eng, name_eng_lower, name_nep, name_nep_norm
|
|
)
|
|
if classified is None:
|
|
continue
|
|
rank, _ = classified
|
|
if rank > best_rank:
|
|
best_rank = rank
|
|
best = location
|
|
if rank == _MATCH_RANK_EXACT:
|
|
return best
|
|
return best
|
|
|
|
|
|
def _is_nepali_text(tokens):
|
|
"""Check if any token contains Devanagari characters."""
|
|
return any(re.search(r"[\u0900-\u097F]", t) for t in tokens)
|
|
|
|
|
|
@lru_cache(maxsize=1024)
|
|
def _find_address_components(address_string: str):
|
|
"""Resolve the (municipality, district, province) location objects.
|
|
|
|
Memoised on ``address_string`` (the input is a small string and the
|
|
location data is static). Returns ``(None, None, None)`` for empty
|
|
input. The location objects come from ``nepali.locations`` and are
|
|
safe to share across callers.
|
|
"""
|
|
if not address_string:
|
|
return None, None, None
|
|
|
|
content = address_string.replace(",", " ").replace("-", " ")
|
|
tokens = [t.strip() for t in content.split() if t.strip()]
|
|
normalized_tokens = [_normalize_nepali_text(t) for t in tokens]
|
|
|
|
found_municipality = _find_location_in_tokens(
|
|
municipalities, tokens, normalized_tokens
|
|
)
|
|
found_district = _find_location_in_tokens(districts, tokens, normalized_tokens)
|
|
found_province = _find_location_in_tokens(provinces, tokens, normalized_tokens)
|
|
|
|
if found_municipality:
|
|
if not found_district:
|
|
found_district = found_municipality.district
|
|
if not found_province:
|
|
found_province = found_municipality.province
|
|
if found_district and not found_province:
|
|
found_province = found_district.province
|
|
|
|
return found_municipality, found_district, found_province
|
|
|
|
|
|
def normalize_address(address_string: str) -> dict[str, Optional[str]]:
|
|
"""
|
|
Best-effort: split a Nepali address string into Province, District, and
|
|
Municipality.
|
|
|
|
Returns a dict with the (English) names of the matched locations, or
|
|
their Nepali names if the input contains Devanagari. The heavy
|
|
matching is memoised on ``address_string``; each call still returns
|
|
a fresh dict so callers can mutate the result without affecting the
|
|
cache.
|
|
"""
|
|
result: dict[str, Optional[str]] = {
|
|
"province": None,
|
|
"district": None,
|
|
"municipality": None,
|
|
}
|
|
if not address_string:
|
|
return result
|
|
|
|
tokens = [t.strip() for t in address_string.split() if t.strip()]
|
|
is_nepali = _is_nepali_text(tokens)
|
|
|
|
municipality, district, province = _find_address_components(address_string)
|
|
|
|
def _name(loc):
|
|
if loc is None:
|
|
return None
|
|
return loc.name_nepali if is_nepali else loc.name
|
|
|
|
result["municipality"] = _name(municipality)
|
|
result["district"] = _name(district)
|
|
result["province"] = _name(province)
|
|
return result
|