commit ff51b802e574dfde9917b24c37314d3ca2d24c97 Author: Jan Doubravský Date: Thu Apr 16 08:49:32 2026 +0200 Initial commit diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4f594a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# AI Agents - Project Rules + +**Document Version:** v4 (independent, incremented on structural changes) + +Rules and instructions for AI assistants (Claude Code, Cursor, Copilot, etc.) + +## First-time setup + +- **On first read of this file, immediately read all other `.md` files in the project root** (e.g. `PROJECT.md`, `CHANGELOG.md`, `DESIGN_DOCUMENT.md`) to get full project context before starting any task. + +## Language + +- **Always write all documentation in English** + +## Dependency Management + +- **Always use `poetry add`** to add dependencies, **never edit `pyproject.toml` directly** + ```bash + poetry add requests + poetry add --group dev pytest + ``` +- Use `poetry remove` to remove dependencies — **never edit `pyproject.toml` manually** + +## Project Structure + +- Entry points are in the project root (named after project or by purpose: `project_name.py`, `cli.py`, `gui.py`, `server.py`) +- A project can have multiple entry points +- All modules belong in the `src/` folder +- Tests belong in the `tests/` folder +- Virtual environment is in `.venv/` (do not copy, do not generate) + +## Code + +- Always use type annotations +- Follow PEP8 and format with Ruff (88 characters per line) +- Before commit run `poetry run ruff check` and `poetry run mypy` + +## Testing + +- **Use pytest exclusively** - never use the `unittest` module +- No `unittest.TestCase` classes, no `self.assert*` methods +- Use plain `assert` statements and pytest fixtures + +## Running + +- Use `poetry run` to run scripts: + ```bash + poetry run python project_name.py + poetry run pytest + ``` + +## Logging + +- Use **loguru** for logging - never use `print()` for debugging +- Never log secrets, passwords, tokens, or API keys + +## Environment and Secrets + +- Store secrets in `.env` file with `ENV_DEBUG=true/false` variable +- Load secrets using `python-dotenv` and `os.getenv()` +- **Never commit `.env` file** + +## Git + +- `.gitignore` should contain: `.venv/`, `__pycache__/`, `*.pyc`, `.mypy_cache/`, `.env` +- Do not commit `poetry.lock` only if it's a library (for applications, commit it) +- **Never commit this documentation** (`DESIGN_DOCUMENT.md`, `AGENTS.md`, `.claudeignore`) +- `PROJECT.md` **should be committed** - it's project-specific + +## Versioning + +- **Always ask user before bumping version** - never increase version automatically +- **Keep `CHANGELOG.md` updated** - document all significant changes as they are made +- Update `CHANGELOG.md` with changes before version bump +- Version is defined in `pyproject.toml` under `[project]` section +- Follow semantic versioning (MAJOR.MINOR.PATCH) + +## Task Management + +- **When completing tasks, mark them as done** - if you finish any task with a checkbox anywhere in project documentation, check it off as completed `[ ]` → `[x]` +- **Track all work** - this applies to tasks in `PROJECT.md` (TODO section, Development Roadmap, any checklists) and other documentation +- **Update documentation** - when completing changes, update relevant sections in `PROJECT.md`, `CHANGELOG.md`, and architecture diagrams +- **Keep task lists current** - completed items with `[x]` stay visible to show progress history diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c11f258 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ + +## First-time setup + +**At the start of every new session, read all of the following files before doing anything else:** + +- `CLAUDE.md` (this file) +- `AGENTS.md` +- `PROJECT.md` +- `CHANGELOG.md` +- `DESIGN_DOCUMENT.md` \ No newline at end of file diff --git a/DESIGN_DOCUMENT_MODULE.md b/DESIGN_DOCUMENT_MODULE.md new file mode 100644 index 0000000..2ec4855 --- /dev/null +++ b/DESIGN_DOCUMENT_MODULE.md @@ -0,0 +1,260 @@ +# Python Library Development Guidelines + +**Document Version:** v1 + +> **Note on Versioning:** +> - This document version is independent — reused across projects +> - **Project version** source of truth: `pyproject.toml` under `[project]` +> - `CHANGELOG.md` uses project version from `pyproject.toml` + +## Related Documents + +- **README.md** — Project overview, public API description, installation and usage examples +- **AGENTS.md** — Rules for AI assistants +- **PROJECT.md** — Project goals and current state +- **CHANGELOG.md** — Version history + +### Documentation Organization + +All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root. + +The root directory contains only the core documents: `DESIGN_DOCUMENT_MODULE.md`, `AGENTS.md`, `PROJECT.md`, `CHANGELOG.md`. + +--- + +## 1. Code Style + +- **PEP8** with 150-character lines (Ruff) +- **4 spaces** indentation +- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants +- **Type hints** required on all functions +- **Import order**: stdlib → third-party → local + +--- + +## 2. SOLID Principles + +- **SRP** — One class = one responsibility +- **OCP** — Open for extension, closed for modification +- **LSP** — Subclasses substitutable for parents +- **ISP** — Small interfaces over large ones +- **DIP** — Depend on abstractions + +--- + +## 3. Dependency Injection + +Pass dependencies via constructor. Never instantiate dependencies inside a class. + +--- + +## 4. Protocols Over Inheritance + +Prefer `typing.Protocol` and composition over class inheritance. + +--- + +## 5. Data Classes + +Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation. + +--- + +## 6. Logging + +This is a library. Libraries must **never configure logging sinks** — that is the responsibility of the consuming application. + +### Usage in library code + +```python +from loguru import logger + +def some_function() -> None: + logger.debug("Detail message") + logger.info("Milestone message") +``` + +Always use the module-level `logger` from loguru directly. Never call `logger.add()` or `logger.remove()` inside library code. + +### Behavior for consumers + +Loguru has a default sink to `stderr` enabled. Consumers who also use loguru get library logs automatically in their configured sinks. Consumers who want to suppress or filter library logs use the package name as the identifier: + +```python +from loguru import logger + +logger.disable("") # suppress all +logger.add(sys.stderr, filter={"": "WARNING"}) # WARNING and above only +logger.enable("") # re-enable +``` + +This must be documented in `README.md`. + +### Rules + +- Never call `logger.add()` inside library code — no file sinks, no stdout sinks +- Never call `logger.remove()` inside library code +- Never log secrets, passwords, tokens, or API keys +- Never use `print()` inside library code — not for debugging, not for output + +#### Log levels + +| Level | When to use | +|-------|-------------| +| `DEBUG` | Per-item detail: individual operations, cache hits, internal state | +| `INFO` | Significant milestones visible to the consuming application | +| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state | +| `ERROR` | Failures the caller must know about — operation cannot continue | + +--- + +## 7. Environment and Secrets + +- Libraries do not use `.env` files or `python-dotenv` +- Configuration is passed by the caller via arguments or constructor parameters +- Never read `os.getenv()` inside library code unless explicitly documented as a supported configuration mechanism + +--- + +## 8. Error Handling + +Define specific exception types in `src//exceptions.py`. Use a fail-fast approach — surface errors early rather than silently continuing. All public exceptions must be exported from the top-level `__init__.py`. + +--- + +## 9. Testing + +- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*` +- Arrange-Act-Assert pattern +- Test naming: `test__` +- Do not commit `poetry.lock` — this is a library; consumers pin their own dependencies + +--- + +## 10. Tooling + +| Tool | Purpose | +|------|---------| +| **Ruff** | Formatting and linting | +| **mypy** | Static type checking | +| **pytest** | Testing | + +Run before every commit: +```bash +poetry run ruff check +poetry run mypy +poetry run pytest +``` + +--- + +## 11. Poetry + +```bash +poetry install # Install all dependencies +poetry add # Add runtime dependency +poetry add --group dev # Add dev dependency +poetry remove # Remove dependency +poetry run # Run command in virtualenv +poetry build # Build sdist and wheel into dist/ +poetry publish # Publish to PyPI +``` + +Never edit `pyproject.toml` directly to add or remove dependencies. + +### pyproject.toml configuration for a library + +```toml +[project] +name = "your-library" +version = "0.1.0" +description = "Short description" +requires-python = ">=3.x" +dependencies = [] + +[tool.poetry] +packages = [{include = "your_library", from = "src"}] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" +``` + +Do **not** add `[tool.poetry.scripts]` — libraries have no entry points. + +### poetry.lock + +Do **not** commit `poetry.lock` for libraries. It is only committed for applications. Add `poetry.lock` to `.gitignore`. + +--- + +## 12. Project Structure + +``` +project/ +├── src/ +│ └── your_library/ +│ ├── __init__.py # Public API — export everything the caller needs +│ ├── exceptions.py # All public exception types +│ └── ... # Internal modules +├── tests/ # Tests +├── docs/ # Detailed documentation +├── .venv/ # Virtual environment (managed by Poetry) +└── pyproject.toml # Project config and dependencies +``` + +- No entry point scripts in the project root — this is a library, not an application +- `__init__.py` defines the public API; callers import from the top-level package only + +--- + +## 13. Public API + +- Everything intended for external use must be exported from `src//__init__.py` +- Use `__all__` to explicitly declare the public surface +- Internal modules are prefixed with `_` or kept unexported +- Public API must be stable across patch versions; breaking changes require a major version bump + +--- + +## 14. Distribution + +Build and publish with Poetry: + +```bash +poetry build # Creates dist/*.tar.gz and dist/*.whl +poetry publish # Publishes to PyPI (requires credentials) +``` + +`dist/` is **not committed** to the repository — add it to `.gitignore`. + +--- + +## 15. Versioning + +- Follow **semantic versioning**: `MAJOR.MINOR.PATCH` +- Version is defined in `pyproject.toml` under `[project]` +- Always ask before bumping the version — never increment automatically +- Update `CHANGELOG.md` before bumping the version +- Breaking changes to the public API require a major version bump + +--- + +## 16. Documentation and Task Management + +- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes +- `README.md` must contain installation instructions and usage examples for the public API +- Document architectural changes in this file or in `docs/` + +### Task notation + +Tasks are written as single-line comments directly in code, or in `PROJECT.md` for cross-cutting concerns: + +```python +# TODO: one-liner description of a task to be done +# FIXME: one-liner description of a known bug to be fixed +``` + +No other task format is used — no checkboxes, no numbered lists in documentation. + +If a `# TODO:` comment already exists at a specific location in code, do not repeat it in `PROJECT.md`. diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..e3adecb --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,88 @@ +# PROJECT.md + +## Project Overview + +**PlanetaryTime** is a Python library for representing and working with time on other bodies in the Solar System, similar in spirit to the standard `datetime` module. + +--- + +## Core Concept + +Time on a given planetary body is expressed using a human-readable clock where **the hour is preserved as close to one Earth hour as possible**. + +The number of hours in a planetary day is derived from the length of that body's sidereal rotation period expressed in Earth hours, rounded to the nearest integer: + +``` +hours_per_day = round(rotation_period_in_earth_hours) +``` + +Example: if a body rotates once every 24.6 Earth hours, its day has 25 hours, each approximately 1 Earth hour long. + +--- + +## Epoch + +The starting point for counting days (sol 0 / day 0) is configurable per body and per use case: + +- **Discovery epoch** — the date the body was officially discovered +- **Contact epoch** — the date of first contact with the body: + - landing of an automated probe, or + - landing of a crewed mission + +The epoch is set when constructing a planetary time object and determines what "day 1" means. + +--- + +## Planned Public API + +```python +from planetarytime import PlanetaryTime, Body, Epoch + +# construct from Earth datetime + target body + epoch choice +pt = PlanetaryTime.from_earth(datetime.utcnow(), body=Body.MARS, epoch=Epoch.DISCOVERY) + +pt.day # integer day number since epoch +pt.hour # hour within the current day (0-indexed) +pt.minute # minute within the current hour +pt.second # second within the current minute +pt.body # the planetary body +pt.epoch # the epoch in use + +str(pt) # human-readable: e.g. "Sol 142, 09:35:22 (Mars / Discovery epoch)" +``` + +--- + +## Supported Bodies + +Initial target: planets and major bodies of the Solar System with known rotation periods. + +| Body | Rotation period (Earth h) | Hours per day | +|---------|--------------------------|---------------| +| Mercury | 1407.6 | 1408 | +| Venus | 5832.5 (retrograde) | 5833 | +| Mars | 24.6 | 25 | +| Jupiter | 9.9 | 10 | +| Saturn | 10.7 | 11 | +| Uranus | 17.2 | 17 | +| Neptune | 16.1 | 16 | + +Moon and dwarf planets (Pluto, Ceres, Eris) may be added later. + +--- + +## Current State + +- Project scaffolded (Poetry, src layout, tests directory) +- No implementation yet + +## TODO + +- Define `Body` enum with rotation periods +- Define `Epoch` enum and epoch registry +- Implement core `PlanetaryTime` class +- Implement conversion from Earth `datetime` +- Implement `__str__` / `__repr__` +- Write tests for conversion accuracy +- Write tests for epoch switching +- Populate README with usage examples diff --git a/PlanetaryTime.code-workspace b/PlanetaryTime.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/PlanetaryTime.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1736527 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# PlanetaryTime + + + +## Installation + +```bash +pip install planetarytime +``` + +## Usage + +```python +import planetarytime + +# TODO: usage examples +``` + +## Logging + +This library uses [loguru](https://github.com/Delgan/loguru) for internal logging. + +By default, loguru has a sink to `stderr` enabled. If your application also uses +loguru, library logs will appear in your configured sinks automatically — no extra +setup needed. + +### Suppressing library logs + +To silence all logs from this library: + +```python +from loguru import logger +logger.disable("") +``` + +To re-enable them: + +```python +logger.enable("") +``` + +### Filtering by level + +If you want library logs only from `WARNING` and above, filter by the package name: + +```python +from loguru import logger +import sys + +logger.add(sys.stderr, filter={"": "WARNING"}) +``` diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..0867df6 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,370 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.9.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443"}, + {file = "librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c"}, + {file = "librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2"}, + {file = "librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b"}, + {file = "librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774"}, + {file = "librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8"}, + {file = "librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671"}, + {file = "librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d"}, + {file = "librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882"}, + {file = "librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a"}, + {file = "librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6"}, + {file = "librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8"}, + {file = "librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a"}, + {file = "librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4"}, + {file = "librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d"}, + {file = "librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2"}, + {file = "librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f"}, + {file = "librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f"}, + {file = "librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745"}, + {file = "librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9"}, + {file = "librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e"}, + {file = "librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22"}, + {file = "librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11"}, + {file = "librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d"}, + {file = "librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd"}, + {file = "librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519"}, + {file = "librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5"}, + {file = "librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb"}, + {file = "librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499"}, + {file = "librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b"}, + {file = "librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9"}, + {file = "librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e"}, + {file = "librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15"}, + {file = "librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40"}, + {file = "librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118"}, + {file = "librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61"}, + {file = "librt-0.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5112c2fb7c2eefefaeaf5c97fec81343ef44ee86a30dcfaa8223822fba6467b4"}, + {file = "librt-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a81eea9b999b985e4bacc650c4312805ea7008fd5e45e1bf221310176a7bcb3a"}, + {file = "librt-0.9.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eea1b54943475f51698f85fa230c65ccac769f1e603b981be060ac5763d90927"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81107843ed1836874b46b310f9b1816abcb89912af627868522461c3b7333c0f"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa95738a68cedd3a6f5492feddc513e2e166b50602958139e47bbdd82da0f5a7"}, + {file = "librt-0.9.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6788207daa0c19955d2b668f3294a368d19f67d9b5f274553fd073c1260cbb9f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f48c963a76d71b9d7927eb817b543d0dccd52ab6648b99d37bd54f4cd475d856"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:42ff8a962554c350d4a83cf47d9b7b78b0e6ff7943e87df7cdfc97c07f3c016f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:657f8ba7b9eaaa82759a104137aed2a3ef7bc46ccfd43e0d89b04005b3e0a4cc"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d03fa4fd277a7974c1978c92c374c57f44edeee163d147b477b143446ad1bf6"}, + {file = "librt-0.9.0-cp39-cp39-win32.whl", hash = "sha256:d9da80e5b04acce03ced8ba6479a71c2a2edf535c2acc0d09c80d2f80f3bad15"}, + {file = "librt-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:54d412e47c21b85865676ed0724e37a89e9593c2eee1e7367adf85bfad56ffb1"}, + {file = "librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d"}, +] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "mypy" +version = "1.20.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3ba5d1e712ada9c3b6223dcbc5a31dac334ed62991e5caa17bcf5a4ddc349af0"}, + {file = "mypy-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e731284c117b0987fb1e6c5013a56f33e7faa1fce594066ab83876183ce1c66"}, + {file = "mypy-1.20.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e945b872a05f4fbefabe2249c0b07b6b194e5e11a86ebee9edf855de09806c"}, + {file = "mypy-1.20.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fc88acef0dc9b15246502b418980478c1bfc9702057a0e1e7598d01a7af8937"}, + {file = "mypy-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:14911a115c73608f155f648b978c5055d16ff974e6b1b5512d7fedf4fa8b15c6"}, + {file = "mypy-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:76d9b4c992cca3331d9793ef197ae360ea44953cf35beb2526e95b9e074f2866"}, + {file = "mypy-1.20.1-cp310-cp310-win_arm64.whl", hash = "sha256:b408722f80be44845da555671a5ef3a0c63f51ca5752b0c20e992dc9c0fbd3cd"}, + {file = "mypy-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c01eb9bac2c6a962d00f9d23421cd2913840e65bba365167d057bd0b4171a92e"}, + {file = "mypy-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55d12ddbd8a9cac5b276878bd534fa39fff5bf543dc6ae18f25d30c8d7d27fca"}, + {file = "mypy-1.20.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0aa322c1468b6cdfc927a44ce130f79bb44bcd34eb4a009eb9f96571fd80955"}, + {file = "mypy-1.20.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f8bc95899cf676b6e2285779a08a998cc3a7b26f1026752df9d2741df3c79e8"}, + {file = "mypy-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47c2b90191a870a04041e910277494b0d92f0711be9e524d45c074fe60c00b65"}, + {file = "mypy-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:9857dc8d2ec1a392ffbda518075beb00ac58859979c79f9e6bdcb7277082c2f2"}, + {file = "mypy-1.20.1-cp311-cp311-win_arm64.whl", hash = "sha256:09d8df92bb25b6065ab91b178da843dda67b33eb819321679a6e98a907ce0e10"}, + {file = "mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51"}, + {file = "mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28"}, + {file = "mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f"}, + {file = "mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37"}, + {file = "mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237"}, + {file = "mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d"}, + {file = "mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019"}, + {file = "mypy-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f3886c03e40afefd327bd70b3f634b39ea82e87f314edaa4d0cce4b927ddcc1"}, + {file = "mypy-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e860eb3904f9764e83bafd70c8250bdffdc7dde6b82f486e8156348bf7ceb184"}, + {file = "mypy-1.20.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4b5aac6e785719da51a84f5d09e9e843d473170a9045b1ea7ea1af86225df4b"}, + {file = "mypy-1.20.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f37b6cd0fe2ad3a20f05ace48ca3523fc52ff86940e34937b439613b6854472e"}, + {file = "mypy-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4bbb0f6b54ce7cc350ef4a770650d15fa70edd99ad5267e227133eda9c94218"}, + {file = "mypy-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3dc20f8ec76eecd77148cdd2f1542ed496e51e185713bf488a414f862deb8f2"}, + {file = "mypy-1.20.1-cp313-cp313-win_arm64.whl", hash = "sha256:a9d62bbac5d6d46718e2b0330b25e6264463ed832722b8f7d4440ff1be3ca895"}, + {file = "mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12"}, + {file = "mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe"}, + {file = "mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08"}, + {file = "mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572"}, + {file = "mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6"}, + {file = "mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3"}, + {file = "mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4"}, + {file = "mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a"}, + {file = "mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986"}, + {file = "mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a"}, + {file = "mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9"}, + {file = "mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02"}, + {file = "mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa"}, + {file = "mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08"}, + {file = "mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06"}, + {file = "mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804"}, +] + +[package.dependencies] +librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, + {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.15.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f"}, + {file = "ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e"}, + {file = "ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609"}, + {file = "ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f"}, + {file = "ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151"}, + {file = "ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8"}, + {file = "ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07"}, + {file = "ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48"}, + {file = "ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5"}, + {file = "ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed"}, + {file = "ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188"}, + {file = "ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14,<4.0" +content-hash = "23e204d0d68650008db039ff23151ebaa1d69730287ca59d2c0eea7c9edbd67d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..30a84ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "planetarytime" +version = "0.1.0" +description = "" +authors = [ + {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.14,<4.0" +dependencies = [ + "loguru (>=0.7.3,<0.8.0)" +] + +[tool.poetry] +packages = [{include = "planetarytime", from = "src"}] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "pytest (>=9.0.3,<10.0.0)", + "mypy (>=1.20.1,<2.0.0)", + "ruff (>=0.15.10,<0.16.0)" +] diff --git a/src/planetarytime/__init__.py b/src/planetarytime/__init__.py new file mode 100644 index 0000000..adccedb --- /dev/null +++ b/src/planetarytime/__init__.py @@ -0,0 +1,13 @@ +from planetarytime.body import Body +from planetarytime.epoch import EpochType +from planetarytime.exceptions import DatetimePrecedesEpochError, EpochUnavailableError, PlanetaryTimeError +from planetarytime.planetary_time import PlanetaryTime + +__all__ = [ + "Body", + "EpochType", + "PlanetaryTime", + "PlanetaryTimeError", + "EpochUnavailableError", + "DatetimePrecedesEpochError", +] diff --git a/src/planetarytime/__pycache__/__init__.cpython-314.pyc b/src/planetarytime/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..dd78f59 Binary files /dev/null and b/src/planetarytime/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/planetarytime/__pycache__/body.cpython-314.pyc b/src/planetarytime/__pycache__/body.cpython-314.pyc new file mode 100644 index 0000000..d40316c Binary files /dev/null and b/src/planetarytime/__pycache__/body.cpython-314.pyc differ diff --git a/src/planetarytime/__pycache__/epoch.cpython-314.pyc b/src/planetarytime/__pycache__/epoch.cpython-314.pyc new file mode 100644 index 0000000..2b26d21 Binary files /dev/null and b/src/planetarytime/__pycache__/epoch.cpython-314.pyc differ diff --git a/src/planetarytime/__pycache__/exceptions.cpython-314.pyc b/src/planetarytime/__pycache__/exceptions.cpython-314.pyc new file mode 100644 index 0000000..1d067ae Binary files /dev/null and b/src/planetarytime/__pycache__/exceptions.cpython-314.pyc differ diff --git a/src/planetarytime/__pycache__/planetary_time.cpython-314.pyc b/src/planetarytime/__pycache__/planetary_time.cpython-314.pyc new file mode 100644 index 0000000..58b19d7 Binary files /dev/null and b/src/planetarytime/__pycache__/planetary_time.cpython-314.pyc differ diff --git a/src/planetarytime/body.py b/src/planetarytime/body.py new file mode 100644 index 0000000..21deb50 --- /dev/null +++ b/src/planetarytime/body.py @@ -0,0 +1,55 @@ +from enum import Enum + + +class Body(Enum): + """Planetary body in the Solar System.""" + + MERCURY = "Mercury" + VENUS = "Venus" + MARS = "Mars" + JUPITER = "Jupiter" + SATURN = "Saturn" + URANUS = "Uranus" + NEPTUNE = "Neptune" + + @property + def rotation_hours(self) -> float: + """Sidereal rotation period in Earth hours.""" + return _ROTATION_HOURS[self] + + @property + def orbital_hours(self) -> float: + """Orbital period around the Sun in Earth hours.""" + return _ORBITAL_HOURS[self] + + @property + def hours_per_sol(self) -> int: + """Number of hours in one sol (rotation period rounded to nearest hour).""" + return round(self.rotation_hours) + + @property + def sols_per_year(self) -> int: + """Number of sols in one planetary year (orbital period / rotation period, rounded).""" + return round(self.orbital_hours / self.rotation_hours) + + +_ROTATION_HOURS: dict[Body, float] = { + Body.MERCURY: 1407.6, + Body.VENUS: 5832.5, + Body.MARS: 24.6, + Body.JUPITER: 9.9, + Body.SATURN: 10.7, + Body.URANUS: 17.2, + Body.NEPTUNE: 16.1, +} + +# Orbital periods in Earth hours +_ORBITAL_HOURS: dict[Body, float] = { + Body.MERCURY: 87.97 * 24, + Body.VENUS: 224.70 * 24, + Body.MARS: 686.97 * 24, + Body.JUPITER: 4332.59 * 24, + Body.SATURN: 10759.22 * 24, + Body.URANUS: 30688.50 * 24, + Body.NEPTUNE: 60182.00 * 24, +} diff --git a/src/planetarytime/epoch.py b/src/planetarytime/epoch.py new file mode 100644 index 0000000..9fdb985 --- /dev/null +++ b/src/planetarytime/epoch.py @@ -0,0 +1,51 @@ +from datetime import datetime, timezone +from enum import Enum + +from planetarytime.body import Body +from planetarytime.exceptions import EpochUnavailableError + + +class EpochType(Enum): + """How the starting day is determined.""" + + DISCOVERY = "discovery" + CONTACT = "contact" + + +# Discovery dates for Solar System bodies (UTC midnight). +_DISCOVERY_DATES: dict[Body, datetime] = { + Body.MERCURY: datetime(1631, 11, 7, tzinfo=timezone.utc), # first recorded transit (Gassendi) + Body.VENUS: datetime(1610, 1, 1, tzinfo=timezone.utc), # telescopic observation (Galileo) + Body.MARS: datetime(1610, 1, 1, tzinfo=timezone.utc), # telescopic observation (Galileo) + Body.JUPITER: datetime(1610, 1, 7, tzinfo=timezone.utc), # moons discovered (Galileo) + Body.SATURN: datetime(1610, 7, 25, tzinfo=timezone.utc), # rings observed (Galileo) + Body.URANUS: datetime(1781, 3, 13, tzinfo=timezone.utc), # Herschel + Body.NEPTUNE: datetime(1846, 9, 23, tzinfo=timezone.utc), # Le Verrier / Galle +} + +# First contact dates — automated probe landing or crewed landing. +# None means no contact has occurred yet. +_CONTACT_DATES: dict[Body, datetime | None] = { + Body.MERCURY: datetime(2011, 3, 18, tzinfo=timezone.utc), # MESSENGER orbit insertion (closest approach) + Body.VENUS: datetime(1970, 12, 15, tzinfo=timezone.utc), # Venera 7 — first soft landing + Body.MARS: datetime(1976, 7, 20, tzinfo=timezone.utc), # Viking 1 — first soft landing + Body.JUPITER: None, + Body.SATURN: None, + Body.URANUS: None, + Body.NEPTUNE: None, +} + + +def get_epoch_date(body: Body, epoch_type: EpochType) -> datetime: + """Return the epoch datetime for a given body and epoch type. + + Raises: + ValueError: if contact epoch is requested but no contact has occurred. + """ + if epoch_type is EpochType.DISCOVERY: + return _DISCOVERY_DATES[body] + + contact = _CONTACT_DATES[body] + if contact is None: + raise EpochUnavailableError(f"No contact with {body.value} has occurred — contact epoch is unavailable.") + return contact diff --git a/src/planetarytime/exceptions.py b/src/planetarytime/exceptions.py new file mode 100644 index 0000000..2411509 --- /dev/null +++ b/src/planetarytime/exceptions.py @@ -0,0 +1,10 @@ +class PlanetaryTimeError(Exception): + """Base exception for all planetarytime errors.""" + + +class EpochUnavailableError(PlanetaryTimeError): + """Raised when a requested epoch has not occurred yet for a given body.""" + + +class DatetimePrecedesEpochError(PlanetaryTimeError): + """Raised when the provided datetime is earlier than the body's epoch.""" diff --git a/src/planetarytime/planetary_time.py b/src/planetarytime/planetary_time.py new file mode 100644 index 0000000..9421344 --- /dev/null +++ b/src/planetarytime/planetary_time.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from loguru import logger + +from planetarytime.body import Body +from planetarytime.epoch import EpochType, get_epoch_date +from planetarytime.exceptions import DatetimePrecedesEpochError + + +class PlanetaryTime: + """Represents a point in time expressed on a specific planetary body. + + Time is anchored to an epoch (discovery or first contact). The clock is + scaled so that one planetary hour is as close to one Earth hour as possible: + - hours_per_sol = rotation period rounded to nearest Earth hour + - sols_per_year = orbital period / rotation period, rounded + + The unit "sol" is used for all non-Earth bodies instead of "day". + """ + + def __init__( + self, + body: Body, + epoch_type: EpochType, + total_seconds_since_epoch: float, + ) -> None: + self._body = body + self._epoch_type = epoch_type + self._total_seconds = total_seconds_since_epoch + + seconds_per_hour: float = 3600.0 + seconds_per_sol: float = body.hours_per_sol * seconds_per_hour + seconds_per_year: float = body.sols_per_year * seconds_per_sol + + total_seconds = max(0.0, total_seconds_since_epoch) + + self._year: int = int(total_seconds // seconds_per_year) + remainder = total_seconds % seconds_per_year + + self._sol: int = int(remainder // seconds_per_sol) + remainder %= seconds_per_sol + + self._hour: int = int(remainder // seconds_per_hour) + remainder %= seconds_per_hour + + self._minute: int = int(remainder // 60) + self._second: int = int(remainder % 60) + + logger.debug( + "PlanetaryTime constructed: body={} epoch={} year={} sol={} {:02d}:{:02d}:{:02d}", + body.value, + epoch_type.value, + self._year, + self._sol, + self._hour, + self._minute, + self._second, + ) + + # ------------------------------------------------------------------ + # Factory + # ------------------------------------------------------------------ + + @classmethod + def from_earth( + cls, + earth_dt: datetime, + body: Body, + epoch_type: EpochType = EpochType.DISCOVERY, + ) -> PlanetaryTime: + """Create a PlanetaryTime from an Earth datetime. + + Args: + earth_dt: Earth datetime (timezone-aware or naive UTC). + body: Target planetary body. + epoch_type: Which epoch to use as year 0, sol 0. + + Returns: + PlanetaryTime instance for the given moment on the target body. + + Raises: + DatetimePrecedesEpochError: if the datetime precedes the epoch. + EpochUnavailableError: if contact epoch is requested but no contact has occurred. + """ + if earth_dt.tzinfo is None: + earth_dt = earth_dt.replace(tzinfo=timezone.utc) + + epoch_dt = get_epoch_date(body, epoch_type) + delta = earth_dt - epoch_dt + total_seconds = delta.total_seconds() + + logger.info("Converting Earth datetime {} to {} time (epoch={})", earth_dt, body.value, epoch_type.value) + + if total_seconds < 0: + raise DatetimePrecedesEpochError( + f"Earth datetime {earth_dt} precedes the {epoch_type.value} epoch " + f"for {body.value} ({epoch_dt})." + ) + + return cls(body, epoch_type, total_seconds) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def body(self) -> Body: + return self._body + + @property + def epoch_type(self) -> EpochType: + return self._epoch_type + + @property + def year(self) -> int: + """Year number since epoch (0-indexed).""" + return self._year + + @property + def sol(self) -> int: + """Sol number within the current year (0-indexed).""" + return self._sol + + @property + def hour(self) -> int: + """Hour within the current sol (0-indexed).""" + return self._hour + + @property + def minute(self) -> int: + """Minute within the current hour (0-indexed).""" + return self._minute + + @property + def second(self) -> int: + """Second within the current minute (0-indexed).""" + return self._second + + # ------------------------------------------------------------------ + # String representation + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + return ( + f"PlanetaryTime(body={self._body.value!r}, epoch={self._epoch_type.value!r}, " + f"year={self._year}, sol={self._sol}, " + f"time={self._hour:02d}:{self._minute:02d}:{self._second:02d})" + ) + + def __str__(self) -> str: + return ( + f"Year {self._year}, Sol {self._sol}, " + f"{self._hour:02d}:{self._minute:02d}:{self._second:02d} " + f"({self._body.value} / {self._epoch_type.value} epoch)" + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..8ad3bbf Binary files /dev/null and b/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/tests/__pycache__/test_body.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_body.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..563f922 Binary files /dev/null and b/tests/__pycache__/test_body.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_planetary_time.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_planetary_time.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..2841e79 Binary files /dev/null and b/tests/__pycache__/test_planetary_time.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/test_body.py b/tests/test_body.py new file mode 100644 index 0000000..f44dede --- /dev/null +++ b/tests/test_body.py @@ -0,0 +1,33 @@ +from planetarytime import Body + + +def test_mars_hours_per_sol() -> None: + assert Body.MARS.hours_per_sol == 25 + + +def test_jupiter_hours_per_sol() -> None: + assert Body.JUPITER.hours_per_sol == 10 + + +def test_hours_per_sol_equals_rounded_rotation() -> None: + for body in Body: + assert body.hours_per_sol == round(body.rotation_hours) + + +def test_all_bodies_have_positive_rotation() -> None: + for body in Body: + assert body.rotation_hours > 0 + + +def test_mars_sols_per_year() -> None: + assert Body.MARS.sols_per_year == 670 + + +def test_all_bodies_have_positive_sols_per_year() -> None: + for body in Body: + assert body.sols_per_year > 0 + + +def test_sols_per_year_derived_from_orbital_and_rotation() -> None: + for body in Body: + assert body.sols_per_year == round(body.orbital_hours / body.rotation_hours) diff --git a/tests/test_planetary_time.py b/tests/test_planetary_time.py new file mode 100644 index 0000000..663c896 --- /dev/null +++ b/tests/test_planetary_time.py @@ -0,0 +1,82 @@ +import pytest +from datetime import datetime, timezone, timedelta + +from planetarytime import Body, EpochType, PlanetaryTime +from planetarytime.exceptions import DatetimePrecedesEpochError, EpochUnavailableError +from planetarytime.epoch import get_epoch_date + + +def test_from_earth_at_epoch_is_year_zero_sol_zero() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY) + assert pt.year == 0 + assert pt.sol == 0 + assert pt.hour == 0 + assert pt.minute == 0 + assert pt.second == 0 + + +def test_from_earth_one_sol_later() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + one_sol_later = epoch_dt + timedelta(hours=Body.MARS.hours_per_sol) + pt = PlanetaryTime.from_earth(one_sol_later, Body.MARS, EpochType.DISCOVERY) + assert pt.year == 0 + assert pt.sol == 1 + assert pt.hour == 0 + + +def test_from_earth_one_year_later() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + one_year_seconds = Body.MARS.sols_per_year * Body.MARS.hours_per_sol * 3600 + one_year_later = epoch_dt + timedelta(seconds=one_year_seconds) + pt = PlanetaryTime.from_earth(one_year_later, Body.MARS, EpochType.DISCOVERY) + assert pt.year == 1 + assert pt.sol == 0 + assert pt.hour == 0 + + +def test_from_earth_one_hour_later() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + one_hour_later = epoch_dt + timedelta(hours=1) + pt = PlanetaryTime.from_earth(one_hour_later, Body.MARS, EpochType.DISCOVERY) + assert pt.year == 0 + assert pt.sol == 0 + assert pt.hour == 1 + assert pt.minute == 0 + + +def test_from_earth_naive_datetime_treated_as_utc() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + naive = epoch_dt.replace(tzinfo=None) + pt = PlanetaryTime.from_earth(naive, Body.MARS, EpochType.DISCOVERY) + assert pt.year == 0 + assert pt.sol == 0 + + +def test_from_earth_before_epoch_raises() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + before_epoch = epoch_dt - timedelta(days=1) + with pytest.raises(DatetimePrecedesEpochError): + PlanetaryTime.from_earth(before_epoch, Body.MARS, EpochType.DISCOVERY) + + +def test_contact_epoch_unavailable_raises() -> None: + with pytest.raises(EpochUnavailableError): + PlanetaryTime.from_earth(datetime(2024, 1, 1, tzinfo=timezone.utc), Body.JUPITER, EpochType.CONTACT) + + +def test_str_contains_body_name_and_sol() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY) + assert "Mars" in str(pt) + assert "Sol" in str(pt) + assert "Year" in str(pt) + + +def test_repr_contains_year_and_sol() -> None: + epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY) + pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY) + r = repr(pt) + assert "PlanetaryTime(" in r + assert "year=" in r + assert "sol=" in r diff --git a/zkouska.py b/zkouska.py new file mode 100644 index 0000000..313a5b6 --- /dev/null +++ b/zkouska.py @@ -0,0 +1,7 @@ +import planetarytime as pt +import datetime + +now = datetime.datetime.now(datetime.timezone.utc) +mars = pt.PlanetaryTime.from_earth(now, pt.Body.VENUS) + +print(mars) \ No newline at end of file