Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Keep the lockfile untouched: it is generated by `make lock`, never hand-edited.
exclude: ^uv\.lock$

repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
Expand Down Expand Up @@ -26,7 +29,8 @@ repos:
- id: check-docstring-first

- repo: https://github.com/PyCQA/isort
rev: 9.0.0a3
# Pin to the latest stable release, not the 9.x pre-releases.
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black"]
Expand Down Expand Up @@ -54,19 +58,15 @@ repos:
hooks:
- id: python-no-eval
- id: python-check-blanket-noqa
- id: python-no-log-warn
- id: python-check-blanket-type-ignore
- id: python-check-mock-methods

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.2.0
rev: v2.3.0
hooks:
- id: mypy

- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
- id: python-no-log-warn
- id: python-check-blanket-type-ignore
- id: python-check-mock-methods

- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.25
hooks:
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ documented workflow.
- Be careful with Jalali/Gregorian conversion edge cases: Norouz boundaries,
Esfand 29/30, Gregorian century leap-year boundaries, min/max supported years,
and timestamp/from-ordinal behavior.
- The calendar model is layered (astronomical leap data for years 1-1177, the
33-year rule plus the ICU4X correction set for 1178-2987, the plain 33-year
rule beyond); see the README "Calendar model" section. Keep every conversion
derived from `_days_before_year`/`is_leap` in `persiantools/jdatetime.py` --
never reintroduce independent conversion arithmetic.
- Timezone behavior should use `zoneinfo`, `datetime.timezone`, and the stdlib
`datetime` model. Do not reintroduce `pytz`.
- Locale-sensitive behavior currently uses `"en"` and `"fa"`. Keep Persian digit
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [6.2.0](https://github.com/majiidd/persiantools/compare/6.1.0...6.2.0) - 2026-08-05

- Adopted the astronomical calendar model (vernal equinox at the 52.5 E meridian) for ancient Jalali years 1-1177, reproducing the official leap-year table of the Iranian calendar authority; conversions on or after Gregorian 1568-03-21 are unchanged.
- Derived every Jalali/Gregorian conversion from `_days_before_year`/`is_leap`, keeping the calendar model consistent across the full supported year range (1-9377).
- Reworked `JalaliDate`/`JalaliDateTime` hot paths for performance with no behavior changes: `strftime()` is 3-8x faster via lazy single-pass directive substitution, and conversions, date arithmetic, comparisons, `fromordinal()`, `week_of_year()`, and `isocalendar()` are 1.5-3x faster.
- Fixed `JalaliDateTime` copy-constructor to preserve `locale` when initialized from another `JalaliDateTime`.
- Marked the package as typed (`py.typed`) and enriched PyPI metadata for typing consumers.
- Simplified `digits.to_word` conversion and expanded digit test coverage.
- Added conversion test coverage validated against official Iranian calendar authority (kabise) data, plus edge-case coverage for underflow, leap-day `replace()`, `combine` fold/tzinfo, and `to_jalali` argument forms.

## [6.1.0](https://github.com/majiidd/persiantools/compare/6.0.2...6.1.0) - 2026-07-10

- Added the `convert-persian-dates` AI agent skill (`.agents/skills/convert-persian-dates/`) so AI coding assistants can convert Shamsi/Jalali and Gregorian dates without guessing calendar math.
Expand Down
2 changes: 1 addition & 1 deletion persiantools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

__title__ = "persiantools"
__url__ = "https://github.com/majiidd/persiantools"
__version__ = "6.1.0"
__version__ = "6.2.0"
__build__ = __version__
__author__ = "Majid Hajiloo"
__author_email__ = "majid.hajiloo@gmail.com"
Expand Down
63 changes: 33 additions & 30 deletions persiantools/digits.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,6 @@
DELI = " و "
NEGATIVE = "منفی "

DECISION = {
10: lambda n, depth: ONES[n - 1],
20: lambda n, depth: RANGE[n - 10],
100: lambda n, depth: TENS[n // 10 - 2] + _to_word(n % 10, True),
1000: lambda n, depth: HUNDREDS[n // 100 - 1] + _to_word(n % 100, True),
1_000_000: lambda n, depth: _to_word(n // 1_000, depth) + BIG_RANGE[0] + _to_word(n % 1_000, True),
1_000_000_000: lambda n, depth: _to_word(n // 1_000_000, depth) + BIG_RANGE[1] + _to_word(n % 1_000_000, True),
1_000_000_000_000: lambda n, depth: _to_word(n // 1_000_000_000, depth)
+ BIG_RANGE[2]
+ _to_word(n % 1_000_000_000, True),
1_000_000_000_000_000: lambda n, depth: _to_word(n // 1_000_000_000_000, depth)
+ BIG_RANGE[3]
+ _to_word(n % 1_000_000_000_000, True),
}


class OutOfRangeException(Exception):
pass
Expand Down Expand Up @@ -177,14 +162,30 @@ def _to_word(number: int, depth: bool) -> str:
if number < 0:
return NEGATIVE + _to_word(-number, depth)

words = ""
if depth:
words = DELI
depth = False

for key in DECISION:
if number < key:
return words + DECISION[key](number, depth)
words = DELI if depth else ""

if number < 10:
return words + ONES[number - 1]
if number < 20:
return words + RANGE[number - 10]
if number < 100:
quotient, remainder = divmod(number, 10)
return words + TENS[quotient - 2] + _to_word(remainder, True)
if number < 1_000:
quotient, remainder = divmod(number, 100)
return words + HUNDREDS[quotient - 1] + _to_word(remainder, True)
if number < 1_000_000:
quotient, remainder = divmod(number, 1_000)
return words + _to_word(quotient, False) + BIG_RANGE[0] + _to_word(remainder, True)
if number < 1_000_000_000:
quotient, remainder = divmod(number, 1_000_000)
return words + _to_word(quotient, False) + BIG_RANGE[1] + _to_word(remainder, True)
if number < 1_000_000_000_000:
quotient, remainder = divmod(number, 1_000_000_000)
return words + _to_word(quotient, False) + BIG_RANGE[2] + _to_word(remainder, True)
if number < 1_000_000_000_000_000:
quotient, remainder = divmod(number, 1_000_000_000_000)
return words + _to_word(quotient, False) + BIG_RANGE[3] + _to_word(remainder, True)

raise OutOfRangeException("number must be lower than 1000000000000000")

Expand Down Expand Up @@ -214,9 +215,11 @@ def _floating_number_to_word(number: float, depth: bool) -> str:
if len(right) > 14:
raise OutOfRangeException("You are allowed to use 14 digits for a floating point")

if right.strip("0"):
left_word = _to_word(int(left), False)
mantissa_index = len(right.rstrip("0")) - 1
stripped_right = right.rstrip("0")
left_int = int(left)
if stripped_right:
left_word = _to_word(left_int, False)
mantissa_index = len(stripped_right) - 1
if mantissa_index >= len(MANTISSA):
raise ValueError("Fractional part is too long")
result = (
Expand All @@ -225,10 +228,10 @@ def _floating_number_to_word(number: float, depth: bool) -> str:
if number < 0:
return NEGATIVE + result
return result
else:
if number < 0:
return NEGATIVE + _to_word(int(left), False)
return _to_word(int(left), False)

if number < 0:
return NEGATIVE + _to_word(left_int, False)
return _to_word(left_int, False)


def to_word(number: Union[int, float]) -> str:
Expand Down
Loading