diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2fd9a2a..951d30b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,13 +1,13 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python -name: Python package +name: CI on: push: - branches: [ "master" ] + branches: [ "main", "master" ] pull_request: - branches: [ "master" ] + branches: [ "main", "master" ] jobs: build: @@ -16,25 +16,24 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 + python -m pip install -e ".[dev]" + - name: Lint with ruff run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + ruff check . + - name: Type check with mypy + run: | + mypy src/ - name: Test with pytest run: | - python lunardate.py -v + pytest --tb=short -q diff --git a/.gitignore b/.gitignore index f72f665..2d7fa67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,27 @@ -*.pyc +__pycache__/ +*.py[cod] +*.pyd *.swp -build -dist +*.swo + +*.egg +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +build/ +dist/ + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ + +.venv/ +venv/ + +.DS_Store +.vscode/ +context_portal/ diff --git a/LICENSE.txt b/LICENSE.txt index f288702..e47434f 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,3 +1,6 @@ +This project is licensed under the GNU General Public License v3.0 only. +See NOTICE.md for attribution and upstream sources. + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 diff --git a/MODERNIZATION_PLAN.md b/MODERNIZATION_PLAN.md new file mode 100644 index 0000000..ab11d6b --- /dev/null +++ b/MODERNIZATION_PLAN.md @@ -0,0 +1,469 @@ +# Audit & Modernization Plan: python-lunardate + +## Overview + +`python-lunardate` is a pure-Python Chinese calendar library that converts between Solar (Gregorian) and Lunar (Chinese) dates for years 1900–2099. The current codebase is a single 457-line file with legacy Python 2 patterns, no type hints, no proper test suite, and outdated packaging. This plan refactors it into a modern, well-structured, fully-typed, well-tested Python package following current best practices. + +## Stack + +Python (pure library — no framework dependencies) + +## Prerequisites + +- [ ] Python 3.10+ installed (target minimum version) +- [ ] `pip` capable of building from `pyproject.toml` +- [ ] `pytest` and `mypy` available (will be declared as dev dependencies) + +--- + +## Audit Findings + +### Critical Issues + +| # | Issue | Location | Severity | +|---|-------|----------|----------| +| 1 | **Dead code**: `day2LunarDate()` (lines 441–449) is incomplete — calls `LunarDate()` with no args, which would crash | `lunardate.py:441` | High | +| 2 | **No `__hash__`**: `__eq__` is defined without `__hash__`, breaking the hash contract. `LunarDate` instances cannot be reliably used in sets or as dict keys | `lunardate.py:256` | High | +| 3 | **No input validation in `__init__`**: Can create nonsensical dates like `LunarDate(9999, 99, 99)` with no error until `toSolarDate()` is called | `lunardate.py:121` | High | +| 4 | **Expensive `__eq__`**: Converts both operands to solar dates via `toSolarDate()` then diffs. Should compare `(year, month, day, isLeapMonth)` tuples directly | `lunardate.py:256-264` | Medium | +| 5 | **Expensive comparisons**: `__lt__` also converts to solar. Should compare tuples with proper leap-month ordering | `lunardate.py:266-278` | Medium | + +### Style / Best Practices Issues + +| # | Issue | Location | +|---|-------|----------| +| 6 | `class LunarDate(object)` — Python 2 explicit base class | `lunardate.py:118` | +| 7 | camelCase method names: `fromSolarDate`, `toSolarDate`, `isLeapMonth`, `leapMonthForYear` — violates PEP 8 | Throughout | +| 8 | No type hints anywhere | Throughout | +| 9 | `__str__` returns repr-style output (`LunarDate(1976, 8, 8, 1)`); `__repr__` is aliased to `__str__` | `lunardate.py:127-130` | +| 10 | No `__slots__` — unnecessary memory overhead for a value type | `lunardate.py:118` | +| 11 | Mutable public attributes on what should be an immutable value type | `lunardate.py:121-125` | +| 12 | Nested functions `_calcDays` and `_calcMonthDay` should be proper methods or module-level helpers | `lunardate.py:206`, `lunardate.py:332` | +| 13 | `yearInfo2yearDay` is a public module-level function but not in `__all__` | `lunardate.py:407` | +| 14 | Module docstring doubles as PyPI long_description, mixing concerns | `lunardate.py:9-111` | + +### Packaging Issues + +| # | Issue | +|---|-------| +| 15 | Legacy `setup.py` — should use `pyproject.toml` | +| 16 | Python 2.7 and 3.4–3.7 classifiers — all EOL | +| 17 | No `requires-python` constraint | +| 18 | No dev dependencies defined | +| 19 | `lunardate.egg-info/` committed to repo | +| 20 | Version string managed manually in source | + +### Testing Issues + +| # | Issue | +|---|-------| +| 21 | Only doctests embedded in source — no proper test suite | +| 22 | No edge case tests (year boundaries, invalid inputs, data table integrity) | +| 23 | No CI workflow file in repository (referenced in README badge but missing from `.github/`) | + +### Architecture Issues + +| # | Issue | +|---|-------| +| 24 | Single monolithic file mixing data table, conversion logic, and public API | +| 25 | 200-element `yearInfos` hex data embedded inline — hard to audit or extend | +| 26 | No caching strategy documentation (though `yearDays` pre-computation is good) | + +--- + +## Architecture Decisions + +### Decision 1: Convert to proper package structure + +- **Choice**: Refactor from single `lunardate.py` module to a `lunardate/` package directory with `__init__.py`, `_data.py`, `_conversions.py`, and `lunardate.py` (class definition). +- **Rationale**: Separates the 200-element data table from logic, makes the codebase navigable, and allows independent testing of conversion functions vs. the `LunarDate` class API. The public API (`from lunardate import LunarDate`) remains unchanged via `__init__.py` re-exports. +- **Alternatives considered**: + - *Keep single file*: Rejected — mixing 50 lines of data, 150 lines of bit-manipulation helpers, and 200 lines of class API in one file hurts maintainability. + - *Move data to JSON/TOML file*: Rejected — the hex data is compact, static, and benefits from being importable Python for pre-computation at module load. A data file adds I/O overhead for no gain. +- **Conport key**: Decision logged as `package-structure` + +### Decision 2: Make LunarDate immutable with `__slots__` + +- **Choice**: Use `__slots__` and read-only properties (backed by private attributes) to make `LunarDate` an immutable value type. Implement `__hash__` based on `(year, month, day, is_leap_month)`. +- **Rationale**: A date is fundamentally a value object. Immutability enables hashing (sets, dict keys), prevents bugs from accidental mutation, and `__slots__` reduces memory footprint per instance. +- **Alternatives considered**: + - *`@dataclass(frozen=True)`*: Attractive but would require Python 3.10+ `__slots__=True` parameter. Also doesn't give us control over `__repr__` format without overriding it anyway. The manual approach with `__slots__` is equally clean and explicit. + - *`NamedTuple`*: Rejected — doesn't support custom methods, `@staticmethod`, or `@classmethod` cleanly. Would require a wrapper class, adding complexity. +- **Conport key**: Decision logged as `immutable-lunardate` + +### Decision 3: Add PEP 8 snake_case API with deprecation aliases + +- **Choice**: Rename all public methods to snake_case (`from_solar_date`, `to_solar_date`, `is_leap_month`, `leap_month_for_year`). Provide camelCase aliases that emit `DeprecationWarning` for one major version, then remove. +- **Rationale**: PEP 8 compliance is expected in modern Python. The aliases preserve backward compatibility for existing users while guiding migration. +- **Alternatives considered**: + - *Break API immediately*: Rejected — existing users (PyPI shows downloads) would break with no migration path. + - *Keep camelCase forever*: Rejected — violates Python conventions and signals an unmaintained library. +- **Conport key**: Decision logged as `pep8-naming-migration` + +### Decision 4: Migrate to `pyproject.toml` with setuptools backend + +- **Choice**: Replace `setup.py` with `pyproject.toml` using `setuptools` as the build backend. Target Python ≥ 3.10. +- **Rationale**: `pyproject.toml` is the PEP 621 standard. setuptools is the most compatible backend for an existing PyPI package. Python 3.10+ gives us `match` statements, `|` union types in annotations, and `__slots__` in dataclasses — all of which are worth the minimum version bump. +- **Alternatives considered**: + - *Hatchling/Flit*: Viable but setuptools is already the implicit backend and requires no new tooling knowledge. The package is simple enough that backend choice is immaterial. + - *Target Python 3.8+*: Rejected — 3.8 and 3.9 are EOL or near-EOL. The library has no users who would reasonably be stuck on 3.8 for a calendar utility. +- **Conport key**: Decision logged as `pyproject-toml-migration` + +### Decision 5: Optimize comparisons to avoid solar conversion + +- **Choice**: Implement `__eq__` and `__lt__` by comparing `(year, month, day, is_leap_month)` tuples directly, with leap month ordering correctly placed after its regular month. +- **Rationale**: Current `__eq__` calls `toSolarDate()` on both operands (which iterates `yearDays` and `_enumMonth`), making every comparison O(N) where N is the year offset. Tuple comparison is O(1). +- **Alternatives considered**: + - *Cache solar date internally*: Would speed up repeated comparisons but adds memory overhead. Direct tuple comparison is simpler and universally fast. +- **Conport key**: Decision logged as `optimize-comparisons` + +### Decision 6: Add comprehensive pytest test suite + +- **Choice**: Create a `tests/` directory with pytest-based tests organized by concern: `test_lunardate.py` (class API), `test_conversions.py` (round-trip and edge cases), `test_data.py` (data table integrity). Migrate existing doctests to proper test functions. Keep a minimal set of doctests in source only for documentation purposes. +- **Rationale**: Doctests are brittle and limited. pytest provides parametrization, fixtures, clear failure messages, and is the Python community standard. +- **Alternatives considered**: + - *Keep doctests as primary tests*: Rejected — they can't handle edge cases, parametrization, or expected exceptions cleanly. +- **Conport key**: Decision logged as `pytest-test-suite` + +--- + +## Data Model Changes + +Not applicable — this is a pure library with no persistence layer. + +--- + +## Target Package Structure + +``` +python-lunardate/ +├── pyproject.toml # PEP 621 metadata + build config +├── README.md # Updated documentation +├── LICENSE.txt # GPLv3 (unchanged) +├── .gitignore # Updated to cover modern Python artifacts +├── src/ +│ └── lunardate/ +│ ├── __init__.py # Public API re-exports: LunarDate, __version__ +│ ├── _data.py # yearInfos table + yearDays pre-computation +│ ├── _conversions.py # _enum_month(), _from_offset(), _year_info_to_days() +│ └── lunardate.py # LunarDate class definition +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Shared fixtures +│ ├── test_lunardate.py # LunarDate class unit tests +│ ├── test_conversions.py # Conversion round-trip + edge cases +│ └── test_data.py # Data table integrity checks +└── .github/ + └── workflows/ + └── ci.yml # GitHub Actions CI +``` + +Uses `src` layout per [setuptools recommendations](https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#src-layout) — prevents accidental import of the source tree instead of the installed package during testing. + +--- + +## Implementation Sequence + +### Step 1: Create `pyproject.toml` and remove `setup.py` + +- **Scope**: `pyproject.toml` (new), `setup.py` (delete), `lunardate.egg-info/` (delete), `.gitignore` (update) +- **Details**: + - Create `pyproject.toml` with: + ```toml + [build-system] + requires = ["setuptools>=68.0"] + build-backend = "setuptools.build_meta" + + [project] + name = "lunardate" + version = "1.0.0" + description = "A Chinese Calendar Library in Pure Python" + readme = "README.md" + license = "GPL-3.0-only" + requires-python = ">=3.10" + authors = [ + {name = "LI Daobing", email = "lidaobing@gmail.com"}, + ] + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", + ] + keywords = ["chinese", "lunar", "calendar", "date", "conversion"] + + [project.optional-dependencies] + dev = ["pytest>=8.0", "mypy>=1.8", "ruff>=0.3"] + + [project.urls] + Homepage = "https://github.com/lidaobing/python-lunardate" + Repository = "https://github.com/lidaobing/python-lunardate" + Issues = "https://github.com/lidaobing/python-lunardate/issues" + + [tool.setuptools.packages.find] + where = ["src"] + + [tool.pytest.ini_options] + testpaths = ["tests"] + + [tool.mypy] + strict = true + + [tool.ruff] + target-version = "py310" + line-length = 99 + + [tool.ruff.lint] + select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"] + ``` + - Delete `setup.py` + - Delete `lunardate.egg-info/` directory + - Update `.gitignore` to include: `*.egg-info/`, `dist/`, `build/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/`, `*.egg`, `.venv/` +- **Tests**: Verify `pip install -e ".[dev]"` succeeds +- **Commit message**: `build: migrate from setup.py to pyproject.toml` + +### Step 2: Create `src/lunardate/_data.py` — extract data table + +- **Scope**: `src/lunardate/_data.py` (new) +- **Details**: + - Move `yearInfos` list from `lunardate.py` → `_data.py` and rename to `YEAR_INFOS` (constant naming) + - Move `yearInfo2yearDay()` → `_data.py` and rename to `_year_info_to_days()` + - Move `yearDays` → `_data.py` and rename to `YEAR_DAYS` + - Add module docstring explaining the bit-encoding format of the year info values + - Add type annotations: `YEAR_INFOS: tuple[int, ...]` (convert list to tuple for immutability), `YEAR_DAYS: tuple[int, ...]` + - Add constants: `START_YEAR: int = 1900`, `END_YEAR: int = 2100` (computed from `START_YEAR + len(YEAR_INFOS)`) + - Add `__all__: list[str] = []` (all symbols are private) +- **Tests**: `test_data.py` — verify `len(YEAR_INFOS) == 200`, verify `len(YEAR_DAYS) == 200`, verify `YEAR_DAYS[i]` is in valid range (348–390), verify `START_YEAR == 1900` and `END_YEAR == 2100` +- **Commit message**: `refactor(data): extract calendar data table to _data module` + +### Step 3: Create `src/lunardate/_conversions.py` — extract conversion logic + +- **Scope**: `src/lunardate/_conversions.py` (new) +- **Details**: + - Move `_enumMonth()` → `_conversions.py` as `enum_month(year_info: int) -> Iterator[tuple[int, int, bool]]` + - Yields `(month, days, is_leap_month)` tuples + - Add full docstring explaining the bit-extraction logic + - Create `offset_to_lunar(offset: int) -> tuple[int, int, int, bool]`: + - Refactored from `LunarDate._fromOffset()` + - Returns `(year, month, day, is_leap_month)` — no `LunarDate` construction here + - Eliminates the nested `_calcMonthDay` function + - Create `lunar_to_offset(year: int, month: int, day: int, is_leap_month: bool) -> int`: + - Refactored from the offset calculation in `LunarDate.toSolarDate()` + - Returns the day offset from `START_DATE` + - Eliminates the nested `_calcDays` function + - Raises `ValueError` for invalid year/month/day + - Create `validate_lunar_date(year: int, month: int, day: int, is_leap_month: bool) -> None`: + - Raises `ValueError` with descriptive messages for invalid dates + - Used by `LunarDate.__init__()` in lazy or eager mode + - All functions fully typed with return annotations + - Import data from `_data.py`: `from lunardate._data import YEAR_INFOS, YEAR_DAYS, START_YEAR, END_YEAR` +- **Tests**: `test_conversions.py` — + - `test_enum_month_no_leap`: Verify 12 months yielded for a non-leap year + - `test_enum_month_with_leap`: Verify 13 months yielded for a leap year, correct insertion position + - `test_offset_to_lunar_roundtrip`: Parametrize over known solar↔lunar pairs + - `test_lunar_to_offset_invalid`: Verify `ValueError` for out-of-range year/month/day + - `test_validate_lunar_date_rejects_invalid`: Test various invalid dates +- **Commit message**: `refactor(core): extract conversion functions to _conversions module` + +### Step 4: Create `src/lunardate/lunardate.py` — modernized LunarDate class + +- **Scope**: `src/lunardate/lunardate.py` (new) +- **Details**: + - Define `class LunarDate` with: + - `__slots__ = ('_year', '_month', '_day', '_is_leap_month')` + - Read-only properties: `year`, `month`, `day`, `is_leap_month` + - `__init__(self, year: int, month: int, day: int, is_leap_month: bool = False)` — stores validated values + - Call `validate_lunar_date()` from `_conversions` for eager validation + - `__repr__` returns `LunarDate(1976, 8, 8, True)` (note: `True`/`False` not `1`/`0`) + - `__str__` returns `1976-08-08 (leap)` or `1976-08-08` — human-readable format + - `__hash__` returns `hash((self._year, self._month, self._day, self._is_leap_month))` + - `__eq__` compares field tuples directly (O(1), not solar conversion) + - `__lt__` compares `(year, month, _leap_sort_key, day)` where `_leap_sort_key` ensures the regular month sorts before its leap variant + - `__le__`, `__gt__`, `__ge__` via `@functools.total_ordering` + - `from_solar_date(cls, year: int, month: int, day: int) -> LunarDate` (classmethod) + - `to_solar_date(self) -> datetime.date` + - `today(cls) -> LunarDate` (classmethod) + - `leap_month_for_year(year: int) -> int | None` (staticmethod) + - `__add__`, `__radd__`, `__sub__`, `__rsub__` — same semantics as current, using `_conversions` functions + - **Deprecation aliases**: Define `fromSolarDate = from_solar_date` etc., wrapped with `warnings.warn("fromSolarDate is deprecated, use from_solar_date", DeprecationWarning, stacklevel=2)`. Consider using a descriptor or simple wrapper function for each alias. + - **Property alias**: `isLeapMonth` property that warns and delegates to `is_leap_month` +- **Tests**: `test_lunardate.py` — full test suite (see Testing Strategy below) +- **Commit message**: `refactor(api): modernize LunarDate class with slots, types, and snake_case` + +### Step 5: Create `src/lunardate/__init__.py` — public API surface + +- **Scope**: `src/lunardate/__init__.py` (new), delete old root `lunardate.py` +- **Details**: + - ```python + """A Chinese Calendar Library in Pure Python.""" + from lunardate.lunardate import LunarDate + + __version__ = "1.0.0" + __all__ = ["LunarDate"] + ``` + - Delete the original root-level `lunardate.py` + - Verify `from lunardate import LunarDate` still works + - Add `py.typed` marker file at `src/lunardate/py.typed` for PEP 561 +- **Tests**: `test_lunardate.py::test_import` — verify `from lunardate import LunarDate` and `lunardate.__version__` +- **Commit message**: `refactor(pkg): establish package structure with public API` + +### Step 6: Write comprehensive test suite + +- **Scope**: `tests/conftest.py`, `tests/test_lunardate.py`, `tests/test_conversions.py`, `tests/test_data.py` (all new) +- **Details**: + - **`conftest.py`**: + - Fixture: `known_pairs` — list of `(solar_date, lunar_year, lunar_month, lunar_day, is_leap)` tuples from the existing doctests plus additional edge cases + - **`test_data.py`**: + - `test_year_infos_length`: 200 entries + - `test_year_days_range`: Each between 348 and 390 + - `test_year_days_matches_year_infos`: `YEAR_DAYS[i] == _year_info_to_days(YEAR_INFOS[i])` for all i + - `test_start_end_year_constants`: `START_YEAR == 1900`, `END_YEAR == 2100` + - **`test_conversions.py`**: + - `test_enum_month_count_no_leap`: Parametrize across non-leap years + - `test_enum_month_count_leap`: Parametrize across leap years, verify 13 yields + - `test_enum_month_leap_position`: Verify leap month appears after its regular counterpart + - `test_offset_roundtrip`: For each known pair, verify `offset_to_lunar(lunar_to_offset(...)) == original` + - `test_validate_rejects_bad_year/month/day/leap` + - **`test_lunardate.py`**: + - `test_construction_valid`: Basic construction and property access + - `test_construction_invalid`: Parametrize invalid dates, verify `ValueError` + - `test_repr`: Verify `repr(LunarDate(1976, 8, 8, True)) == "LunarDate(1976, 8, 8, True)"` + - `test_str`: Verify human-readable format + - `test_from_solar_date`: Parametrize known conversions + - `test_to_solar_date`: Parametrize known conversions (reverse direction) + - `test_roundtrip_solar_lunar_solar`: For a range of dates, verify `ld.to_solar_date()` → `from_solar_date()` → same `ld` + - `test_today`: Verify type and that it doesn't raise + - `test_leap_month_for_year`: Known leap years + non-leap years + - `test_hash_consistency`: Equal dates have equal hashes; can be used in sets + - `test_eq_same/different/non_lunardate` + - `test_ordering`: Parametrize pairs and verify `<`, `<=`, `>`, `>=` + - `test_add_timedelta/sub_timedelta/sub_lunardate/sub_date` + - `test_radd/rsub` + - `test_deprecated_camel_case_warns`: Verify `DeprecationWarning` for old names + - `test_immutability`: Verify `AttributeError` when trying to set `year`/`month`/`day`/`is_leap_month` + - `test_slots`: Verify no `__dict__` on instances + - **Edge cases**: + - Year boundaries (Dec 31 → Jan 1 crossing in both calendars) + - First and last supported dates (1900-01-31, ~2100-02-08) + - Leap month boundaries (day before, first day, last day, day after) +- **Commit message**: `test: add comprehensive pytest test suite` + +### Step 7: Update documentation + +- **Scope**: `README.md` (rewrite) +- **Details**: + - Modern README structure: + - Brief description + - Installation: `pip install lunardate` + - Quick start with updated API examples (snake_case) + - Migration guide: table mapping old camelCase → new snake_case names + - API reference section with all public methods documented + - Development section: `pip install -e ".[dev]"`, `pytest`, `mypy src/`, `ruff check .` + - Version history (keep existing, add 1.0.0 entry) + - Limits section (1900–2099) + - License + - Keep badges, update if needed +- **Tests**: N/A +- **Commit message**: `docs: rewrite README with modern API and migration guide` + +### Step 8: Add GitHub Actions CI + +- **Scope**: `.github/workflows/ci.yml` (new) +- **Details**: + - Matrix: Python 3.10, 3.11, 3.12, 3.13 + - Steps: checkout, setup-python, `pip install -e ".[dev]"`, `ruff check .`, `mypy src/`, `pytest --tb=short -q` + - Trigger on push to `main` and pull requests +- **Tests**: CI itself validates tests pass +- **Commit message**: `ci: add GitHub Actions workflow for lint, type-check, and test` + +### Step 9: Final cleanup + +- **Scope**: Various +- **Details**: + - Remove old `lunardate.egg-info/` from git tracking: `git rm -r lunardate.egg-info/` + - Verify `.gitignore` covers all generated artifacts + - Run full suite: `ruff check .`, `mypy src/`, `pytest -v` + - Verify `python -m build` produces a clean sdist and wheel +- **Tests**: Full CI pass +- **Commit message**: `chore: remove build artifacts and finalize cleanup` + +--- + +## Testing Strategy + +### Unit Tests +- **`test_data.py`**: Data table structural integrity — correct lengths, value ranges, constant correctness +- **`test_conversions.py`**: Pure function tests — `enum_month`, `offset_to_lunar`, `lunar_to_offset`, `validate_lunar_date` +- **`test_lunardate.py`**: Class API tests — construction, conversion, arithmetic, comparison, hashing, immutability, deprecation warnings + +### Parametrized Test Data +Known solar ↔ lunar pairs (from existing doctests + supplementary): +| Solar Date | Lunar Year | Month | Day | Leap? | +|------------|-----------|-------|-----|-------| +| 1900-01-31 | 1900 | 1 | 1 | No | +| 1976-10-01 | 1976 | 8 | 8 | Yes | +| 2008-10-02 | 2008 | 9 | 4 | No | +| 2033-10-23 | 2033 | 10 | 1 | No | +| 1956-12-02 | 1956 | 11 | 1 | No | +| 2088-05-17 | 2088 | 4 | 27 | No | +| 2088-06-17 | 2088 | 4 | 28 | Yes | +| 2088-07-17 | 2088 | 5 | 29 | No | + +### Edge Cases to Cover +- First supported date: solar 1900-01-31 = lunar 1900/1/1 +- Last supported year: lunar 2099/12/last-day +- Invalid year: 1899, 2100 +- Invalid month: 0, 13 +- Invalid day: 0, 31 (for 29-day months) +- Leap month that doesn't exist for the year +- Arithmetic across year boundaries +- Comparison between leap and non-leap month of same year/month + +### Type Checking +- `mypy --strict src/` must pass with zero errors +- All public methods fully annotated with parameter and return types + +--- + +## Risks & Open Questions + +### Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Breaking existing users who depend on camelCase API | High | Medium | Deprecation aliases with `DeprecationWarning` for at least one version cycle | +| `__repr__` format change (`1` → `True` for leap month) breaks code that `eval()`s repr output | Low | Low | Document the change; `eval(repr(x))` was never a supported contract | +| `__eq__` behavior change if any currently-"equal" dates differ by field but coincide in solar conversion | Very Low | Medium | This would indicate a bug in the original, not a regression. Test thoroughly. | +| Validation in `__init__` could break code that constructs invalid dates intentionally | Low | Low | Can't construct invalid dates with the current API without hitting an error at `toSolarDate()` anyway | + +### Open Questions + +1. **Version number**: Should this be `1.0.0` (indicating a mature, stable release) or `0.3.0` (indicated incremental improvement)? **Recommendation**: `1.0.0` — the library is 15+ years old and functionally complete. +2. **`__str__` format**: The proposed `"1976-08-08 (leap)"` format is new. Should it use ISO-like format `"L1976-08-08"` or something else? **Recommendation**: `"LunarDate(1976, 8, 8, True)"` for both `__repr__` **and** `__str__` initially to minimize surprises, then add a `strftime()`-like method later. +3. **Eager vs. lazy validation**: Should `LunarDate(9999, 1, 1)` raise in `__init__` or defer to `to_solar_date()`? **Recommendation**: Eager — fail fast. This is a breaking change but improves correctness. + +--- + +## Conport Updates + +After implementation, the following should be recorded: + +| Type | Key/Tag | Content | +|------|---------|---------| +| Decision | `package-structure` | Convert to `src/lunardate/` package layout | +| Decision | `immutable-lunardate` | Make `LunarDate` immutable with `__slots__` and `__hash__` | +| Decision | `pep8-naming-migration` | snake_case API with deprecation aliases | +| Decision | `pyproject-toml-migration` | Replace `setup.py` with `pyproject.toml`, target Python ≥3.10 | +| Decision | `optimize-comparisons` | Direct tuple comparison instead of solar conversion | +| Decision | `pytest-test-suite` | Comprehensive pytest suite replacing doctests | +| Progress | `audit-complete` | Full codebase audit done | +| Progress | `plan-complete` | Modernization plan written | +| System Pattern | `src-layout` | Using src/ layout for package structure | +| System Pattern | `deprecation-alias` | Pattern for migrating public API names with warnings | +| Active Context | `current_focus` | Modernization of python-lunardate | diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..6b76919 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,11 @@ +Attribution Notice +================== + +This project is a modernization of the original `python-lunardate` library by +LI Daobing (lidaobing@gmail.com), licensed under GPL-3.0-only. + +Upstream repository: +https://github.com/lidaobing/python-lunardate + +The conversion data and algorithms are derived from the C program `lunar`: +http://packages.qa.debian.org/l/lunar.html diff --git a/README.md b/README.md index d54c0a3..5f646a6 100644 --- a/README.md +++ b/README.md @@ -4,92 +4,90 @@ [![PyPI - Version](https://img.shields.io/pypi/v/lunardate)](https://pypi.org/project/lunardate/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/lunardate)](https://pypistats.org/packages/lunardate) - Chinese Calendar: http://en.wikipedia.org/wiki/Chinese_calendar ## Install -``` +```bash pip install lunardate ``` -## Usage +## Quick Start + +```python +import datetime + +from lunardate import LunarDate + +lunar = LunarDate.from_solar_date(1976, 10, 1) +assert repr(lunar) == "LunarDate(1976, 8, 8, True)" +solar = lunar.to_solar_date() +assert solar == datetime.date(1976, 10, 1) + +today = LunarDate.today() ``` - >>> from lunardate import LunarDate - >>> LunarDate.fromSolarDate(1976, 10, 1) - LunarDate(1976, 8, 8, 1) - >>> LunarDate(1976, 8, 8, 1).toSolarDate() - datetime.date(1976, 10, 1) - >>> LunarDate(1976, 8, 8, 1).year - 1976 - >>> LunarDate(1976, 8, 8, 1).month - 8 - >>> LunarDate(1976, 8, 8, 1).day - 8 - >>> LunarDate(1976, 8, 8, 1).isLeapMonth - True - - >>> today = LunarDate.today() - >>> type(today).__name__ - 'LunarDate' - - >>> # support '+' and '-' between datetime.date and datetime.timedelta - >>> ld = LunarDate(1976,8,8) - >>> sd = datetime.date(2008,1,1) - >>> td = datetime.timedelta(days=10) - >>> ld-ld - datetime.timedelta(0) - >>> ld-sd - datetime.timedelta(-11444) - >>> ld-td - LunarDate(1976, 7, 27, 0) - >>> sd-ld - datetime.timedelta(11444) - >>> ld+td - LunarDate(1976, 8, 18, 0) - >>> td+ld - LunarDate(1976, 8, 18, 0) - >>> ld2 = LunarDate.today() - >>> ld < ld2 - True - >>> ld <= ld2 - True - >>> ld > ld2 - False - >>> ld >= ld2 - False - >>> ld == ld2 - False - >>> ld != ld2 - True - >>> ld == ld - True - >>> LunarDate.today() == LunarDate.today() - True - - >>> LunarDate.leapMonthForYear(2023) - 2 - >>> LunarDate.leapMonthForYear(2022) - None + +## API Highlights + +- `LunarDate.from_solar_date(year, month, day) -> LunarDate` +- `LunarDate.to_solar_date() -> datetime.date` +- `LunarDate.leap_month_for_year(year) -> int | None` +- Arithmetic with `datetime.timedelta` + +## Migration Guide (0.2.x → 1.0.0) + +Old camelCase names are still available as deprecated aliases and emit `DeprecationWarning`. + +| Old Name | New Name | +|----------|----------| +| `fromSolarDate` | `from_solar_date` | +| `toSolarDate` | `to_solar_date` | +| `leapMonthForYear` | `leap_month_for_year` | +| `isLeapMonth` | `is_leap_month` | + +## Development + +```bash +pip install -e ".[dev]" +pytest +mypy src/ +ruff check . ``` -## News +## Version History -* 0.2.2: add LunarDate.leapMonthForYear; fix bug in year 1899 -* 0.2.1: fix bug in year 1956 -* 0.2.0: extend year to 2099, thanks to @FuGangqiang -* 0.1.5: fix bug in `==` -* 0.1.4: support '+', '-' and compare, fix bug in year 2050 -* 0.1.3: support python 3.0 +- 1.0.0: Modernized package layout, typed API, pytest suite, PEP 8 method names +- 0.2.2: add LunarDate.leapMonthForYear; fix bug in year 1899 +- 0.2.1: fix bug in year 1956 +- 0.2.0: extend year to 2099, thanks to @FuGangqiang +- 0.1.5: fix bug in `==` +- 0.1.4: support '+', '-' and compare, fix bug in year 2050 +- 0.1.3: support python 3.0 ## Limits -this library can only deal with year from 1900 to 2099 (in chinese calendar). +This library can only deal with years from 1900 to 2099 (Chinese calendar years). + +## Attribution + +This project is a modernization of the original `python-lunardate` library by +LI Daobing (lidaobing@gmail.com). Upstream source: +https://github.com/lidaobing/python-lunardate + +The conversion data and algorithms are derived from the C program `lunar`: +http://packages.qa.debian.org/l/lunar.html + +See `NOTICE.md` for attribution details. + +## License + +Licensed under the GNU General Public License v3.0 only (GPL-3.0-only). +See `LICENSE.txt` and `NOTICE.md`. ## See also -* lunar: http://packages.qa.debian.org/l/lunar.html, +- lunar: http://packages.qa.debian.org/l/lunar.html, A converter written in C, this program is derived from it. -* python-lunar: http://code.google.com/p/liblunar/ +- python-lunar: http://code.google.com/p/liblunar/ Another library written in C, including a python binding. diff --git a/lunardate.egg-info/PKG-INFO b/lunardate.egg-info/PKG-INFO deleted file mode 100644 index 840233b..0000000 --- a/lunardate.egg-info/PKG-INFO +++ /dev/null @@ -1,125 +0,0 @@ -Metadata-Version: 2.1 -Name: lunardate -Version: 0.2.2 -Summary: A Chinese Calendar Library in Pure Python -Home-page: https://github.com/lidaobing/python-lunardate -Author: LI Daobing -Author-email: lidaobing@gmail.com -License: GPLv3 -Classifier: Development Status :: 4 - Beta -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 2 -Classifier: Programming Language :: Python :: 2.7 -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.4 -Classifier: Programming Language :: Python :: 3.5 -Classifier: Programming Language :: Python :: 3.6 -Classifier: Programming Language :: Python :: 3.7 -Classifier: License :: OSI Approved :: GNU General Public License (GPL) -Classifier: Operating System :: OS Independent -Classifier: Topic :: Software Development :: Libraries :: Python Modules -License-File: LICENSE.txt - - -A Chinese Calendar Library in Pure Python -========================================= - -Chinese Calendar: http://en.wikipedia.org/wiki/Chinese_calendar - -Usage ------ - >>> LunarDate.fromSolarDate(1976, 10, 1) - LunarDate(1976, 8, 8, 1) - >>> LunarDate(1976, 8, 8, 1).toSolarDate() - datetime.date(1976, 10, 1) - >>> LunarDate(1976, 8, 8, 1).year - 1976 - >>> LunarDate(1976, 8, 8, 1).month - 8 - >>> LunarDate(1976, 8, 8, 1).day - 8 - >>> LunarDate(1976, 8, 8, 1).isLeapMonth - True - - >>> today = LunarDate.today() - >>> type(today).__name__ - 'LunarDate' - - >>> # support '+' and '-' between datetime.date and datetime.timedelta - >>> ld = LunarDate(1976,8,8) - >>> sd = datetime.date(2008,1,1) - >>> td = datetime.timedelta(days=10) - >>> ld-ld - datetime.timedelta(0) - >>> (ld-sd).days - -11444 - >>> ld-td - LunarDate(1976, 7, 27, 0) - >>> (sd-ld).days - 11444 - >>> ld+td - LunarDate(1976, 8, 18, 0) - >>> td+ld - LunarDate(1976, 8, 18, 0) - >>> ld2 = LunarDate.today() - >>> ld < ld2 - True - >>> ld <= ld2 - True - >>> ld > ld2 - False - >>> ld >= ld2 - False - >>> ld == ld2 - False - >>> ld != ld2 - True - >>> ld == ld - True - >>> LunarDate.today() == LunarDate.today() - True - >>> before_leap_month = LunarDate.fromSolarDate(2088, 5, 17) - >>> before_leap_month.year - 2088 - >>> before_leap_month.month - 4 - >>> before_leap_month.day - 27 - >>> before_leap_month.isLeapMonth - False - >>> leap_month = LunarDate.fromSolarDate(2088, 6, 17) - >>> leap_month.year - 2088 - >>> leap_month.month - 4 - >>> leap_month.day - 28 - >>> leap_month.isLeapMonth - True - >>> after_leap_month = LunarDate.fromSolarDate(2088, 7, 17) - >>> after_leap_month.year - 2088 - >>> after_leap_month.month - 5 - >>> after_leap_month.day - 29 - >>> after_leap_month.isLeapMonth - False - - >>> LunarDate.leapMonthForYear(2023) - 2 - >>> LunarDate.leapMonthForYear(2022) - None - -Limits ------- - -this library can only deal with year from 1900 to 2099 (in chinese calendar). - -See also --------- - -* lunar: http://packages.qa.debian.org/l/lunar.html, - A converter written in C, this program is derived from it. -* python-lunar: http://code.google.com/p/liblunar/ - Another library written in C, including a python binding. diff --git a/lunardate.egg-info/SOURCES.txt b/lunardate.egg-info/SOURCES.txt deleted file mode 100644 index b37ab12..0000000 --- a/lunardate.egg-info/SOURCES.txt +++ /dev/null @@ -1,8 +0,0 @@ -LICENSE.txt -README.md -lunardate.py -setup.py -lunardate.egg-info/PKG-INFO -lunardate.egg-info/SOURCES.txt -lunardate.egg-info/dependency_links.txt -lunardate.egg-info/top_level.txt \ No newline at end of file diff --git a/lunardate.egg-info/dependency_links.txt b/lunardate.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/lunardate.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lunardate.egg-info/top_level.txt b/lunardate.egg-info/top_level.txt deleted file mode 100644 index 4f91082..0000000 --- a/lunardate.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -lunardate diff --git a/lunardate.py b/lunardate.py deleted file mode 100644 index cd23782..0000000 --- a/lunardate.py +++ /dev/null @@ -1,456 +0,0 @@ -# this file is derived from lunar project. -# -# lunar project: -# Copyright (C) 1988,1989,1991,1992,2001 Fung F. Lee and Ricky Yeung -# Licensed under GPLv2. -# -# Copyright (C) 2008 LI Daobing - -''' -A Chinese Calendar Library in Pure Python -========================================= - -Chinese Calendar: http://en.wikipedia.org/wiki/Chinese_calendar - -Usage ------ - >>> LunarDate.fromSolarDate(1976, 10, 1) - LunarDate(1976, 8, 8, 1) - >>> LunarDate(1976, 8, 8, 1).toSolarDate() - datetime.date(1976, 10, 1) - >>> LunarDate(1976, 8, 8, 1).year - 1976 - >>> LunarDate(1976, 8, 8, 1).month - 8 - >>> LunarDate(1976, 8, 8, 1).day - 8 - >>> LunarDate(1976, 8, 8, 1).isLeapMonth - True - - >>> today = LunarDate.today() - >>> type(today).__name__ - 'LunarDate' - - >>> # support '+' and '-' between datetime.date and datetime.timedelta - >>> ld = LunarDate(1976,8,8) - >>> sd = datetime.date(2008,1,1) - >>> td = datetime.timedelta(days=10) - >>> ld-ld - datetime.timedelta(0) - >>> (ld-sd).days - -11444 - >>> ld-td - LunarDate(1976, 7, 27, 0) - >>> (sd-ld).days - 11444 - >>> ld+td - LunarDate(1976, 8, 18, 0) - >>> td+ld - LunarDate(1976, 8, 18, 0) - >>> ld2 = LunarDate.today() - >>> ld < ld2 - True - >>> ld <= ld2 - True - >>> ld > ld2 - False - >>> ld >= ld2 - False - >>> ld == ld2 - False - >>> ld != ld2 - True - >>> ld == ld - True - >>> LunarDate.today() == LunarDate.today() - True - >>> before_leap_month = LunarDate.fromSolarDate(2088, 5, 17) - >>> before_leap_month.year - 2088 - >>> before_leap_month.month - 4 - >>> before_leap_month.day - 27 - >>> before_leap_month.isLeapMonth - False - >>> leap_month = LunarDate.fromSolarDate(2088, 6, 17) - >>> leap_month.year - 2088 - >>> leap_month.month - 4 - >>> leap_month.day - 28 - >>> leap_month.isLeapMonth - True - >>> after_leap_month = LunarDate.fromSolarDate(2088, 7, 17) - >>> after_leap_month.year - 2088 - >>> after_leap_month.month - 5 - >>> after_leap_month.day - 29 - >>> after_leap_month.isLeapMonth - False - - >>> LunarDate.leapMonthForYear(2023) - 2 - >>> LunarDate.leapMonthForYear(2022) # will return None - -Limits ------- - -this library can only deal with year from 1900 to 2099 (in chinese calendar). - -See also --------- - -* lunar: http://packages.qa.debian.org/l/lunar.html, - A converter written in C, this program is derived from it. -* python-lunar: http://code.google.com/p/liblunar/ - Another library written in C, including a python binding. -''' - -import datetime - -__version__ = "0.2.2" -__all__ = ['LunarDate'] - -class LunarDate(object): - _startDate = datetime.date(1900, 1, 31) - - def __init__(self, year, month, day, isLeapMonth=False): - self.year = year - self.month = month - self.day = day - self.isLeapMonth = bool(isLeapMonth) - - def __str__(self): - return 'LunarDate(%d, %d, %d, %d)' % (self.year, self.month, self.day, self.isLeapMonth) - - __repr__ = __str__ - - @staticmethod - def leapMonthForYear(year): - ''' - return None if no leap month, otherwise return the leap month of the year. - return 1 for the first month, and return 12 for the last month. - - >>> LunarDate.leapMonthForYear(1976) - 8 - >>> LunarDate.leapMonthForYear(2023) - 2 - >>> LunarDate.leapMonthForYear(2022) - ''' - start_year = 1900 - end_year = start_year + len(yearInfos) - if year < start_year or year >= end_year: - raise ValueError('year out of range [{}, {})'.format(start_year, end_year)) - yearIdx = year - start_year - yearInfo = yearInfos[yearIdx] - leapMonth = yearInfo % 16 - if leapMonth == 0: - return None - elif leapMonth <= 12: - return leapMonth - else: - raise ValueError("yearInfo %r mod 16 should in [0, 12]" % yearInfo) - - @staticmethod - def fromSolarDate(year, month, day): - ''' - >>> LunarDate.fromSolarDate(1900, 1, 31) - LunarDate(1900, 1, 1, 0) - >>> LunarDate.fromSolarDate(2008, 10, 2) - LunarDate(2008, 9, 4, 0) - >>> LunarDate.fromSolarDate(1976, 10, 1) - LunarDate(1976, 8, 8, 1) - >>> LunarDate.fromSolarDate(2033, 10, 23) - LunarDate(2033, 10, 1, 0) - >>> LunarDate.fromSolarDate(1956, 12, 2) - LunarDate(1956, 11, 1, 0) - ''' - solarDate = datetime.date(year, month, day) - offset = (solarDate - LunarDate._startDate).days - return LunarDate._fromOffset(offset) - - def toSolarDate(self): - ''' - >>> LunarDate(1900, 1, 1).toSolarDate() - datetime.date(1900, 1, 31) - >>> LunarDate(2008, 9, 4).toSolarDate() - datetime.date(2008, 10, 2) - >>> LunarDate(1976, 8, 8, 1).toSolarDate() - datetime.date(1976, 10, 1) - >>> LunarDate(1976, 7, 8, 1).toSolarDate() - Traceback (most recent call last): - ... - ValueError: month out of range - >>> LunarDate(1899, 1, 1).toSolarDate() - Traceback (most recent call last): - ... - ValueError: year out of range [1900, 2100) - >>> LunarDate(2004, 1, 30).toSolarDate() - Traceback (most recent call last): - ... - ValueError: day out of range - >>> LunarDate(2004, 13, 1).toSolarDate() - Traceback (most recent call last): - ... - ValueError: month out of range - >>> LunarDate(2100, 1, 1).toSolarDate() - Traceback (most recent call last): - ... - ValueError: year out of range [1900, 2100) - >>> - ''' - def _calcDays(yearInfo, month, day, isLeapMonth): - isLeapMonth = int(isLeapMonth) - res = 0 - ok = False - for _month, _days, _isLeapMonth in self._enumMonth(yearInfo): - if (_month, _isLeapMonth) == (month, isLeapMonth): - if 1 <= day <= _days: - res += day - 1 - return res - else: - raise ValueError("day out of range") - res += _days - - raise ValueError("month out of range") - - offset = 0 - start_year = 1900 - end_year = start_year + len(yearInfos) - if self.year < start_year or self.year >= end_year: - raise ValueError('year out of range [{}, {})'.format(start_year, end_year)) - yearIdx = self.year - start_year - for i in range(yearIdx): - offset += yearDays[i] - - offset += _calcDays(yearInfos[yearIdx], self.month, self.day, self.isLeapMonth) - return self._startDate + datetime.timedelta(days=offset) - - def __sub__(self, other): - if isinstance(other, LunarDate): - return self.toSolarDate() - other.toSolarDate() - elif isinstance(other, datetime.date): - return self.toSolarDate() - other - elif isinstance(other, datetime.timedelta): - res = self.toSolarDate() - other - return LunarDate.fromSolarDate(res.year, res.month, res.day) - raise TypeError - - def __rsub__(self, other): - if isinstance(other, datetime.date): - return other - self.toSolarDate() - - def __add__(self, other): - if isinstance(other, datetime.timedelta): - res = self.toSolarDate() + other - return LunarDate.fromSolarDate(res.year, res.month, res.day) - raise TypeError - - def __radd__(self, other): - return self + other - - def __eq__(self, other): - ''' - >>> LunarDate.today() == 5 - False - ''' - if not isinstance(other, LunarDate): - return False - - return self - other == datetime.timedelta(0) - - def __lt__(self, other): - ''' - >>> LunarDate.today() < LunarDate.today() - False - >>> LunarDate.today() < 5 - Traceback (most recent call last): - ... - TypeError: can't compare LunarDate to int - ''' - try: - return self - other < datetime.timedelta(0) - except TypeError: - raise TypeError("can't compare LunarDate to %s" % (type(other).__name__,)) - - def __le__(self, other): - # needed because the default implementation tries equality first, - # and that does not throw a type error - return self < other or self == other - - def __gt__(self, other): - ''' - >>> LunarDate.today() > LunarDate.today() - False - >>> LunarDate.today() > 5 - Traceback (most recent call last): - ... - TypeError: can't compare LunarDate to int - ''' - return not self <= other - - def __ge__(self, other): - ''' - >>> LunarDate.today() >= LunarDate.today() - True - >>> LunarDate.today() >= 5 - Traceback (most recent call last): - ... - TypeError: can't compare LunarDate to int - ''' - return not self < other - - @classmethod - def today(cls): - res = datetime.date.today() - return cls.fromSolarDate(res.year, res.month, res.day) - - @staticmethod - def _enumMonth(yearInfo): - months = [(i, 0) for i in range(1, 13)] - leapMonth = yearInfo % 16 - if leapMonth == 0: - pass - elif leapMonth <= 12: - months.insert(leapMonth, (leapMonth, 1)) - else: - raise ValueError("yearInfo %r mod 16 should in [0, 12]" % yearInfo) - - for month, isLeapMonth in months: - if isLeapMonth: - days = (yearInfo >> 16) % 2 + 29 - else: - days = (yearInfo >> (16 - month)) % 2 + 29 - yield month, days, isLeapMonth - - @classmethod - def _fromOffset(cls, offset): - def _calcMonthDay(yearInfo, offset): - for month, days, isLeapMonth in cls._enumMonth(yearInfo): - if offset < days: - break - offset -= days - return (month, offset + 1, isLeapMonth) - - offset = int(offset) - - for idx, yearDay in enumerate(yearDays): - if offset < yearDay: - break - offset -= yearDay - year = 1900 + idx - - yearInfo = yearInfos[idx] - month, day, isLeapMonth = _calcMonthDay(yearInfo, offset) - return LunarDate(year, month, day, isLeapMonth) - -yearInfos = [ - # /* encoding: - # b bbbbbbbbbbbb bbbb - # bit# 1 111111000000 0000 - # 6 543210987654 3210 - # . ............ .... - # month# 000000000111 - # M 123456789012 L - # - # b_j = 1 for long month, b_j = 0 for short month - # L is the leap month of the year if 1<=L<=12; NO leap month if L = 0. - # The leap month (if exists) is long one iff M = 1. - # */ - 0x04bd8, # /* 1900 */ - 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950,# /* 1905 */ - 0x16554, 0x056a0, 0x09ad0, 0x055d2, 0x04ae0,# /* 1910 */ - 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540,# /* 1915 */ - 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, 0x04970,# /* 1920 */ - 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54,# /* 1925 */ - 0x02b60, 0x09570, 0x052f2, 0x04970, 0x06566,# /* 1930 */ - 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60,# /* 1935 */ - 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, 0x0d4a0,# /* 1940 */ - 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0,# /* 1945 */ - 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, 0x06ca0,# /* 1950 */ - 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573,# /* 1955 */ - 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, 0x0aea6,# /* 1960 */ - 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260,# /* 1965 */ - 0x0f263, 0x0d950, 0x05b57, 0x056a0, 0x096d0,# /* 1970 */ - 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250,# /* 1975 */ - 0x0d558, 0x0b540, 0x0b5a0, 0x195a6, 0x095b0,# /* 1980 */ - 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50,# /* 1985 */ - 0x06d40, 0x0af46, 0x0ab60, 0x09570, 0x04af5,# /* 1990 */ - 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58,# /* 1995 */ - 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, 0x0c960,# /* 2000 */ - 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0,# /* 2005 */ - 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, 0x0a950,# /* 2010 */ - 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0,# /* 2015 */ - 0x0a5b0, 0x15176, 0x052b0, 0x0a930, 0x07954,# /* 2020 */ - 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6,# /* 2025 */ - 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, 0x05aa0,# /* 2030 */ - 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0,# /* 2035 */ - 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, 0x0b5a0,# /* 2040 */ - 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0,# /* 2045 */ - 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, 0x14b63,# /* 2050 */ - 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6,# /* 2055 */ - 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0, 0x092e0,# /* 2060 */ - 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50,# /* 2065 */ - 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, 0x052d0,# /* 2070 */ - 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50,# /* 2075 */ - 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, 0x0b273,# /* 2080 */ - 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55,# /* 2085 */ - 0x04b60, 0x0a570, 0x054e4, 0x0d160, 0x0e968,# /* 2090 */ - 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0,# /* 2095 */ - 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, # /* 2099 */ -] - -def yearInfo2yearDay(yearInfo): - '''calculate the days in a lunar year from the lunar year's info - - >>> yearInfo2yearDay(0) # no leap month, and every month has 29 days. - 348 - >>> yearInfo2yearDay(1) # 1 leap month, and every month has 29 days. - 377 - >>> yearInfo2yearDay((2**12-1)*16) # no leap month, and every month has 30 days. - 360 - >>> yearInfo2yearDay((2**13-1)*16+1) # 1 leap month, and every month has 30 days. - 390 - >>> # 1 leap month, and every normal month has 30 days, and leap month has 29 days. - >>> yearInfo2yearDay((2**12-1)*16+1) - 389 - ''' - yearInfo = int(yearInfo) - - res = 29 * 12 - - leap = False - if yearInfo % 16 != 0: - leap = True - res += 29 - - yearInfo //= 16 - - for i in range(12 + leap): - if yearInfo % 2 == 1: - res += 1 - yearInfo //= 2 - return res - -yearDays = [yearInfo2yearDay(x) for x in yearInfos] - -def day2LunarDate(offset): - offset = int(offset) - res = LunarDate() - - for idx, yearDay in enumerate(yearDays): - if offset < yearDay: - break - offset -= yearDay - res.year = 1900 + idx - -if __name__ == '__main__': - import doctest - failure_count, test_count = doctest.testmod() - if failure_count > 0: - import sys - sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc391d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "lunardate" +version = "1.0.0" +description = "A Chinese Calendar Library in Pure Python" +readme = "README.md" +requires-python = ">=3.10" +license = "GPL-3.0-only" +license-files = ["LICENSE.txt"] +authors = [ + { name = "LI Daobing", email = "lidaobing@gmail.com" }, +] +keywords = ["chinese", "lunar", "calendar", "date", "conversion"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +[project.optional-dependencies] +dev = [ + "build>=1.0", + "mypy>=1.8", + "pytest>=8.0", + "ruff>=0.3", +] + +[project.urls] +Homepage = "https://github.com/lidaobing/python-lunardate" +Repository = "https://github.com/lidaobing/python-lunardate" +Issues = "https://github.com/lidaobing/python-lunardate/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +lunardate = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unused_configs = true + +[tool.ruff] +target-version = "py310" +line-length = 99 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"] diff --git a/setup.py b/setup.py deleted file mode 100755 index 02c97b8..0000000 --- a/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup, find_packages - -import lunardate - -setup(name='lunardate', - version=lunardate.__version__, - py_modules = ['lunardate'], - description = 'A Chinese Calendar Library in Pure Python', - long_description = lunardate.__doc__, - author = 'LI Daobing', - author_email = 'lidaobing@gmail.com', - url = 'https://github.com/lidaobing/python-lunardate', - license = 'GPLv3', - classifiers = [ - 'Development Status :: 4 - Beta', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Operating System :: OS Independent', - 'Topic :: Software Development :: Libraries :: Python Modules' - ] - ) diff --git a/src/lunardate/__init__.py b/src/lunardate/__init__.py new file mode 100644 index 0000000..cd33c3f --- /dev/null +++ b/src/lunardate/__init__.py @@ -0,0 +1,8 @@ +"""A Chinese Calendar Library in Pure Python.""" + +from __future__ import annotations + +from lunardate.lunardate import LunarDate + +__all__ = ["LunarDate"] +__version__ = "1.0.0" diff --git a/src/lunardate/_conversions.py b/src/lunardate/_conversions.py new file mode 100644 index 0000000..906641b --- /dev/null +++ b/src/lunardate/_conversions.py @@ -0,0 +1,118 @@ +"""Conversion helpers for Chinese lunar calendar calculations.""" + +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +from lunardate._data import END_YEAR, START_YEAR, YEAR_DAYS, YEAR_INFOS + +_START_DATE = datetime.date(1900, 1, 31) + + +def enum_month(year_info: int) -> Iterator[tuple[int, int, bool]]: + """Yield (month, days, is_leap_month) tuples for the given lunar year info.""" + months = [(i, False) for i in range(1, 13)] + leap_month = year_info % 16 + if leap_month == 0: + pass + elif 1 <= leap_month <= 12: + months.insert(leap_month, (leap_month, True)) + else: + raise ValueError(f"yearInfo {year_info!r} mod 16 should be in [0, 12]") + + for month, is_leap_month in months: + days = ( + (year_info >> 16) % 2 + 29 + if is_leap_month + else (year_info >> (16 - month)) % 2 + 29 + ) + yield month, days, is_leap_month + + +def leap_month_for_year(year: int) -> int | None: + """Return the leap month for the given year, or None if no leap month.""" + if year < START_YEAR or year >= END_YEAR: + raise ValueError(f"year out of range [{START_YEAR}, {END_YEAR})") + year_info = YEAR_INFOS[year - START_YEAR] + leap_month = year_info % 16 + if leap_month == 0: + return None + if 1 <= leap_month <= 12: + return leap_month + raise ValueError(f"yearInfo {year_info!r} mod 16 should be in [0, 12]") + + +def validate_lunar_date(year: int, month: int, day: int, is_leap_month: bool) -> None: + """Validate lunar date components, raising ValueError for invalid inputs.""" + if year < START_YEAR or year >= END_YEAR: + raise ValueError(f"year out of range [{START_YEAR}, {END_YEAR})") + if month < 1 or month > 12: + raise ValueError("month out of range") + + year_info = YEAR_INFOS[year - START_YEAR] + leap_month = year_info % 16 + if is_leap_month and leap_month != month: + raise ValueError("month out of range") + if is_leap_month and leap_month == 0: + raise ValueError("month out of range") + + for enum_month_value, days, enum_is_leap in enum_month(year_info): + if (enum_month_value, enum_is_leap) == (month, bool(is_leap_month)): + if 1 <= day <= days: + return + raise ValueError("day out of range") + + raise ValueError("month out of range") + + +def lunar_to_offset(year: int, month: int, day: int, is_leap_month: bool) -> int: + """Convert a lunar date to day offset from the lunar epoch start date.""" + validate_lunar_date(year, month, day, is_leap_month) + + offset = 0 + year_index = year - START_YEAR + for i in range(year_index): + offset += YEAR_DAYS[i] + + year_info = YEAR_INFOS[year_index] + for enum_month_value, days, enum_is_leap in enum_month(year_info): + if (enum_month_value, enum_is_leap) == (month, bool(is_leap_month)): + offset += day - 1 + return offset + offset += days + + raise ValueError("month out of range") + + +def offset_to_lunar(offset: int) -> tuple[int, int, int, bool]: + """Convert a day offset from the lunar epoch start date into a lunar date tuple.""" + offset = int(offset) + + for idx, year_days in enumerate(YEAR_DAYS): + if offset < year_days: + year = START_YEAR + idx + year_info = YEAR_INFOS[idx] + break + offset -= year_days + else: + raise ValueError("offset out of range") + for month, days, is_leap_month in enum_month(year_info): + if offset < days: + return year, month, offset + 1, is_leap_month + offset -= days + + raise ValueError("offset out of range") + + +def solar_to_lunar(year: int, month: int, day: int) -> tuple[int, int, int, bool]: + """Convert a solar (Gregorian) date to a lunar date tuple.""" + solar_date = datetime.date(year, month, day) + offset = (solar_date - _START_DATE).days + return offset_to_lunar(offset) + + +def lunar_to_solar(year: int, month: int, day: int, is_leap_month: bool) -> datetime.date: + """Convert a lunar date to a solar (Gregorian) date.""" + offset = lunar_to_offset(year, month, day, is_leap_month) + return _START_DATE + datetime.timedelta(days=offset) diff --git a/src/lunardate/_data.py b/src/lunardate/_data.py new file mode 100644 index 0000000..4729a74 --- /dev/null +++ b/src/lunardate/_data.py @@ -0,0 +1,244 @@ +"""Calendar data table and helpers for Chinese lunar year calculations. + +Encoding for each YEAR_INFOS entry (yearInfo): + +- Lower 4 bits (L): leap month number (1-12) or 0 if no leap month. +- Next 12 bits (M): month length flags for months 1-12 (1=30 days, 0=29 days). +- Next 1 bit: leap month length flag (1=30 days, 0=29 days). +""" + +from __future__ import annotations + +from typing import Final + +__all__: list[str] = [] + +START_YEAR: Final[int] = 1900 + +YEAR_INFOS: Final[tuple[int, ...]] = ( + 0x04BD8, # 1900 + 0x04AE0, + 0x0A570, + 0x054D5, + 0x0D260, + 0x0D950, # 1905 + 0x16554, + 0x056A0, + 0x09AD0, + 0x055D2, + 0x04AE0, # 1910 + 0x0A5B6, + 0x0A4D0, + 0x0D250, + 0x1D255, + 0x0B540, # 1915 + 0x0D6A0, + 0x0ADA2, + 0x095B0, + 0x14977, + 0x04970, # 1920 + 0x0A4B0, + 0x0B4B5, + 0x06A50, + 0x06D40, + 0x1AB54, # 1925 + 0x02B60, + 0x09570, + 0x052F2, + 0x04970, + 0x06566, # 1930 + 0x0D4A0, + 0x0EA50, + 0x06E95, + 0x05AD0, + 0x02B60, # 1935 + 0x186E3, + 0x092E0, + 0x1C8D7, + 0x0C950, + 0x0D4A0, # 1940 + 0x1D8A6, + 0x0B550, + 0x056A0, + 0x1A5B4, + 0x025D0, # 1945 + 0x092D0, + 0x0D2B2, + 0x0A950, + 0x0B557, + 0x06CA0, # 1950 + 0x0B550, + 0x15355, + 0x04DA0, + 0x0A5D0, + 0x14573, # 1955 + 0x052B0, + 0x0A9A8, + 0x0E950, + 0x06AA0, + 0x0AEA6, # 1960 + 0x0AB50, + 0x04B60, + 0x0AAE4, + 0x0A570, + 0x05260, # 1965 + 0x0F263, + 0x0D950, + 0x05B57, + 0x056A0, + 0x096D0, # 1970 + 0x04DD5, + 0x04AD0, + 0x0A4D0, + 0x0D4D4, + 0x0D250, # 1975 + 0x0D558, + 0x0B540, + 0x0B5A0, + 0x195A6, + 0x095B0, # 1980 + 0x049B0, + 0x0A974, + 0x0A4B0, + 0x0B27A, + 0x06A50, # 1985 + 0x06D40, + 0x0AF46, + 0x0AB60, + 0x09570, + 0x04AF5, # 1990 + 0x04970, + 0x064B0, + 0x074A3, + 0x0EA50, + 0x06B58, # 1995 + 0x05AC0, + 0x0AB60, + 0x096D5, + 0x092E0, + 0x0C960, # 2000 + 0x0D954, + 0x0D4A0, + 0x0DA50, + 0x07552, + 0x056A0, # 2005 + 0x0ABB7, + 0x025D0, + 0x092D0, + 0x0CAB5, + 0x0A950, # 2010 + 0x0B4A0, + 0x0BAA4, + 0x0AD50, + 0x055D9, + 0x04BA0, # 2015 + 0x0A5B0, + 0x15176, + 0x052B0, + 0x0A930, + 0x07954, # 2020 + 0x06AA0, + 0x0AD50, + 0x05B52, + 0x04B60, + 0x0A6E6, # 2025 + 0x0A4E0, + 0x0D260, + 0x0EA65, + 0x0D530, + 0x05AA0, # 2030 + 0x076A3, + 0x096D0, + 0x04AFB, + 0x04AD0, + 0x0A4D0, # 2035 + 0x1D0B6, + 0x0D250, + 0x0D520, + 0x0DD45, + 0x0B5A0, # 2040 + 0x056D0, + 0x055B2, + 0x049B0, + 0x0A577, + 0x0A4B0, # 2045 + 0x0AA50, + 0x1B255, + 0x06D20, + 0x0ADA0, + 0x14B63, # 2050 + 0x09370, + 0x049F8, + 0x04970, + 0x064B0, + 0x168A6, # 2055 + 0x0EA50, + 0x06AA0, + 0x1A6C4, + 0x0AAE0, + 0x092E0, # 2060 + 0x0D2E3, + 0x0C960, + 0x0D557, + 0x0D4A0, + 0x0DA50, # 2065 + 0x05D55, + 0x056A0, + 0x0A6D0, + 0x055D4, + 0x052D0, # 2070 + 0x0A9B8, + 0x0A950, + 0x0B4A0, + 0x0B6A6, + 0x0AD50, # 2075 + 0x055A0, + 0x0ABA4, + 0x0A5B0, + 0x052B0, + 0x0B273, # 2080 + 0x06930, + 0x07337, + 0x06AA0, + 0x0AD50, + 0x14B55, # 2085 + 0x04B60, + 0x0A570, + 0x054E4, + 0x0D160, + 0x0E968, # 2090 + 0x0D520, + 0x0DAA0, + 0x16AA6, + 0x056D0, + 0x04AE0, # 2095 + 0x0A9D4, + 0x0A2D0, + 0x0D150, + 0x0F252, # 2099 +) + + +def _year_info_to_days(year_info: int) -> int: + """Calculate the days in a lunar year from the encoded year info.""" + year_info = int(year_info) + + total = 29 * 12 + + leap_month = year_info % 16 + has_leap = leap_month != 0 + if has_leap: + total += 29 + + year_info //= 16 + + for _ in range(12 + int(has_leap)): + if year_info % 2 == 1: + total += 1 + year_info //= 2 + + return total + + +YEAR_DAYS: Final[tuple[int, ...]] = tuple(_year_info_to_days(x) for x in YEAR_INFOS) +END_YEAR: Final[int] = START_YEAR + len(YEAR_INFOS) diff --git a/src/lunardate/lunardate.py b/src/lunardate/lunardate.py new file mode 100644 index 0000000..0d1c051 --- /dev/null +++ b/src/lunardate/lunardate.py @@ -0,0 +1,146 @@ +"""Public LunarDate API.""" + +from __future__ import annotations + +import datetime +import functools +import warnings + +from lunardate import _conversions + + +@functools.total_ordering +class LunarDate: + """Value type representing a Chinese lunar date.""" + + __slots__ = ("_day", "_is_leap_month", "_month", "_year") + + def __init__(self, year: int, month: int, day: int, is_leap_month: bool = False) -> None: + _conversions.validate_lunar_date(year, month, day, is_leap_month) + self._year = int(year) + self._month = int(month) + self._day = int(day) + self._is_leap_month = bool(is_leap_month) + + @property + def year(self) -> int: + return self._year + + @property + def month(self) -> int: + return self._month + + @property + def day(self) -> int: + return self._day + + @property + def is_leap_month(self) -> bool: + return self._is_leap_month + + @property + def isLeapMonth(self) -> bool: # noqa: N802 + warnings.warn( + "isLeapMonth is deprecated; use is_leap_month", + DeprecationWarning, + stacklevel=2, + ) + return self._is_leap_month + + def __repr__(self) -> str: + return ( + f"LunarDate({self._year}, {self._month}, {self._day}, {self._is_leap_month})" + ) + + __str__ = __repr__ + + def __hash__(self) -> int: + return hash((self._year, self._month, self._day, self._is_leap_month)) + + def _sort_key(self) -> tuple[int, int, int, int]: + leap_sort = 1 if self._is_leap_month else 0 + return self._year, self._month, leap_sort, self._day + + def __eq__(self, other: object) -> bool: + if not isinstance(other, LunarDate): + return False + return self._sort_key() == other._sort_key() + + def __lt__(self, other: object) -> bool: + if not isinstance(other, LunarDate): + raise TypeError(f"can't compare LunarDate to {type(other).__name__}") + return self._sort_key() < other._sort_key() + + def __add__(self, other: datetime.timedelta) -> LunarDate: + if isinstance(other, datetime.timedelta): + solar = self.to_solar_date() + other + return self.from_solar_date(solar.year, solar.month, solar.day) + raise TypeError + + def __radd__(self, other: datetime.timedelta) -> LunarDate: + return self + other + + def __sub__(self, other: object) -> datetime.timedelta | LunarDate: + if isinstance(other, LunarDate): + return self.to_solar_date() - other.to_solar_date() + if isinstance(other, datetime.date): + return self.to_solar_date() - other + if isinstance(other, datetime.timedelta): + solar = self.to_solar_date() - other + return self.from_solar_date(solar.year, solar.month, solar.day) + raise TypeError + + def __rsub__(self, other: object) -> datetime.timedelta: + if isinstance(other, datetime.date): + return other - self.to_solar_date() + raise TypeError + + @classmethod + def today(cls) -> LunarDate: + today = datetime.date.today() + return cls.from_solar_date(today.year, today.month, today.day) + + @classmethod + def from_solar_date(cls, year: int, month: int, day: int) -> LunarDate: + lunar_year, lunar_month, lunar_day, is_leap_month = _conversions.solar_to_lunar( + year, month, day + ) + return cls(lunar_year, lunar_month, lunar_day, is_leap_month) + + @staticmethod + def leap_month_for_year(year: int) -> int | None: + return _conversions.leap_month_for_year(year) + + def to_solar_date(self) -> datetime.date: + return _conversions.lunar_to_solar( + self._year, + self._month, + self._day, + self._is_leap_month, + ) + + @classmethod + def fromSolarDate(cls, year: int, month: int, day: int) -> LunarDate: # noqa: N802 + warnings.warn( + "fromSolarDate is deprecated; use from_solar_date", + DeprecationWarning, + stacklevel=2, + ) + return cls.from_solar_date(year, month, day) + + def toSolarDate(self) -> datetime.date: # noqa: N802 + warnings.warn( + "toSolarDate is deprecated; use to_solar_date", + DeprecationWarning, + stacklevel=2, + ) + return self.to_solar_date() + + @staticmethod + def leapMonthForYear(year: int) -> int | None: # noqa: N802 + warnings.warn( + "leapMonthForYear is deprecated; use leap_month_for_year", + DeprecationWarning, + stacklevel=2, + ) + return _conversions.leap_month_for_year(year) diff --git a/src/lunardate/py.typed b/src/lunardate/py.typed new file mode 100644 index 0000000..448cef1 --- /dev/null +++ b/src/lunardate/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 typed package. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9b57749 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import datetime +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = PROJECT_ROOT / "src" +sys.path.insert(0, str(SRC_PATH)) + + +@pytest.fixture() +def known_pairs() -> list[tuple[datetime.date, int, int, int, bool]]: + return [ + (datetime.date(1900, 1, 31), 1900, 1, 1, False), + (datetime.date(1956, 12, 2), 1956, 11, 1, False), + (datetime.date(1976, 10, 1), 1976, 8, 8, True), + (datetime.date(2008, 10, 2), 2008, 9, 4, False), + (datetime.date(2033, 10, 23), 2033, 10, 1, False), + (datetime.date(2088, 5, 17), 2088, 4, 27, False), + (datetime.date(2088, 6, 17), 2088, 4, 28, True), + (datetime.date(2088, 7, 17), 2088, 5, 29, False), + ] diff --git a/tests/test_conversions.py b/tests/test_conversions.py new file mode 100644 index 0000000..e82b385 --- /dev/null +++ b/tests/test_conversions.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import datetime + +import pytest + +from lunardate._conversions import ( + enum_month, + leap_month_for_year, + lunar_to_offset, + lunar_to_solar, + offset_to_lunar, + solar_to_lunar, + validate_lunar_date, +) + + +@pytest.mark.parametrize("year", [1900, 1910, 1956, 2008, 2022]) +def test_enum_month_count_no_leap(year: int) -> None: + leap = leap_month_for_year(year) + if leap is not None: + pytest.skip("year has leap month") + + months = list(enum_month(_year_info_for(year))) + assert len(months) == 12 + + +@pytest.mark.parametrize("year", [1976, 2033, 2088]) +def test_enum_month_count_with_leap(year: int) -> None: + leap = leap_month_for_year(year) + assert leap is not None + + months = list(enum_month(_year_info_for(year))) + assert len(months) == 13 + leap_positions = [idx for idx, (m, _, is_leap) in enumerate(months) if is_leap] + assert len(leap_positions) == 1 + assert months[leap_positions[0]][0] == leap + + +@pytest.mark.parametrize( + "year, month, day, is_leap", + [ + (1900, 1, 1, False), + (1976, 8, 8, True), + (2008, 9, 4, False), + (2088, 4, 27, False), + ], +) +def test_offset_roundtrip(year: int, month: int, day: int, is_leap: bool) -> None: + offset = lunar_to_offset(year, month, day, is_leap) + assert offset_to_lunar(offset) == (year, month, day, is_leap) + + +def test_validate_rejects_bad_year() -> None: + with pytest.raises(ValueError, match="year out of range"): + validate_lunar_date(1899, 1, 1, False) + + +def test_validate_rejects_bad_month() -> None: + with pytest.raises(ValueError, match="month out of range"): + validate_lunar_date(1900, 13, 1, False) + + +def test_validate_rejects_bad_day() -> None: + with pytest.raises(ValueError, match="day out of range"): + validate_lunar_date(1900, 1, 40, False) + + +def test_solar_to_lunar_known_pairs( + known_pairs: list[tuple[datetime.date, int, int, int, bool]], +) -> None: + for solar, year, month, day, is_leap in known_pairs: + assert solar_to_lunar(solar.year, solar.month, solar.day) == (year, month, day, is_leap) + + +def test_lunar_to_solar_known_pairs( + known_pairs: list[tuple[datetime.date, int, int, int, bool]], +) -> None: + for solar, year, month, day, is_leap in known_pairs: + assert lunar_to_solar(year, month, day, is_leap) == solar + + +def _year_info_for(year: int) -> int: + from lunardate._data import START_YEAR, YEAR_INFOS + + return YEAR_INFOS[year - START_YEAR] diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..f9bc101 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from lunardate._data import END_YEAR, START_YEAR, YEAR_DAYS, YEAR_INFOS, _year_info_to_days + + +def test_year_infos_length() -> None: + assert len(YEAR_INFOS) == 200 + + +def test_year_days_length() -> None: + assert len(YEAR_DAYS) == 200 + + +def test_year_days_range() -> None: + for days in YEAR_DAYS: + assert 348 <= days <= 390 + + +def test_year_days_match_year_infos() -> None: + for info, days in zip(YEAR_INFOS, YEAR_DAYS, strict=True): + assert _year_info_to_days(info) == days + + +def test_start_end_year_constants() -> None: + assert START_YEAR == 1900 + assert END_YEAR == 2100 diff --git a/tests/test_lunardate.py b/tests/test_lunardate.py new file mode 100644 index 0000000..a8925ac --- /dev/null +++ b/tests/test_lunardate.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import datetime + +import pytest + +from lunardate import LunarDate + + +def test_import_version() -> None: + import lunardate + + assert lunardate.__version__ == "1.0.0" + + +def test_construction_valid() -> None: + lunar = LunarDate(1976, 8, 8, True) + assert lunar.year == 1976 + assert lunar.month == 8 + assert lunar.day == 8 + assert lunar.is_leap_month is True + + +@pytest.mark.parametrize( + "year, month, day, is_leap", + [ + (1899, 1, 1, False), + (2100, 1, 1, False), + (2000, 0, 1, False), + (2000, 13, 1, False), + (2000, 1, 0, False), + (2000, 1, 40, False), + ], +) +def test_construction_invalid(year: int, month: int, day: int, is_leap: bool) -> None: + with pytest.raises(ValueError): + LunarDate(year, month, day, is_leap) + + +def test_repr_str() -> None: + lunar = LunarDate(1976, 8, 8, True) + assert repr(lunar) == "LunarDate(1976, 8, 8, True)" + assert str(lunar) == "LunarDate(1976, 8, 8, True)" + + +def test_hash_consistency() -> None: + a = LunarDate(2008, 9, 4, False) + b = LunarDate(2008, 9, 4, False) + assert a == b + assert hash(a) == hash(b) + assert len({a, b}) == 1 + + +def test_today() -> None: + today = LunarDate.today() + assert isinstance(today, LunarDate) + + +def test_from_solar_date(known_pairs: list[tuple[datetime.date, int, int, int, bool]]) -> None: + for solar, year, month, day, is_leap in known_pairs: + lunar = LunarDate.from_solar_date(solar.year, solar.month, solar.day) + assert (lunar.year, lunar.month, lunar.day, lunar.is_leap_month) == ( + year, + month, + day, + is_leap, + ) + + +def test_to_solar_date(known_pairs: list[tuple[datetime.date, int, int, int, bool]]) -> None: + for solar, year, month, day, is_leap in known_pairs: + lunar = LunarDate(year, month, day, is_leap) + assert lunar.to_solar_date() == solar + + +def test_roundtrip(known_pairs: list[tuple[datetime.date, int, int, int, bool]]) -> None: + for solar, _, _, _, _ in known_pairs: + lunar = LunarDate.from_solar_date(solar.year, solar.month, solar.day) + assert lunar.to_solar_date() == solar + + +def test_leap_month_for_year() -> None: + assert LunarDate.leap_month_for_year(2023) == 2 + assert LunarDate.leap_month_for_year(2022) is None + + +def test_arithmetic() -> None: + lunar = LunarDate(1976, 8, 8, True) + delta = datetime.timedelta(days=10) + assert lunar + delta == LunarDate(1976, 8, 18, True) + assert delta + lunar == LunarDate(1976, 8, 18, True) + + solar = datetime.date(2008, 1, 1) + assert (lunar - solar).days == -11414 + assert (solar - lunar).days == 11414 + + +def test_comparison() -> None: + a = LunarDate(1976, 8, 8, False) + b = LunarDate(1976, 8, 8, True) + c = LunarDate(1976, 8, 9, True) + assert a < b < c + + +def test_immutability() -> None: + lunar = LunarDate(1976, 8, 8, True) + with pytest.raises(AttributeError): + lunar.year = 2000 # type: ignore[misc] + + +def test_deprecated_aliases() -> None: + with pytest.warns(DeprecationWarning): + LunarDate.fromSolarDate(1976, 10, 1) + with pytest.warns(DeprecationWarning): + LunarDate(1976, 8, 8, True).toSolarDate() + with pytest.warns(DeprecationWarning): + LunarDate.leapMonthForYear(2023) + + +def test_deprecated_property() -> None: + lunar = LunarDate(1976, 8, 8, True) + with pytest.warns(DeprecationWarning): + assert lunar.isLeapMonth is True