diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index 22ba7263..ac9ab59b 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -1,4 +1,4 @@ -name: Decimo Unit Tests +name: CI on: pull_request: workflow_dispatch: @@ -7,66 +7,218 @@ permissions: contents: read pull-requests: read -jobs: - testing-decimo: - name: with ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # os: ["macos-latest"] - os: ["ubuntu-22.04"] +defaults: + run: + shell: bash - runs-on: ${{ matrix.os }} - timeout-minutes: 30 +# Shared setup repeated per job (GitHub Actions has no job-level includes, +# so we keep each job self-contained for clarity and parallelism). - defaults: - run: - shell: bash +jobs: + # ── Test: BigDecimal ───────────────────────────────────────────────────────── + test-bigdecimal: + name: Test BigDecimal + runs-on: ubuntu-22.04 + timeout-minutes: 30 env: DEBIAN_FRONTEND: noninteractive - steps: - - name: Checkout repo - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH + run: | + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages + run: | + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_bigdecimal.sh + # ── Test: BigInt ───────────────────────────────────────────────────────────── + test-bigint: + name: Test BigInt + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH + run: | + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages run: | - curl -fsSL https://pixi.sh/install.sh | sh - - - name: Add path + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_bigint.sh + + # ── Test: BigUint ──────────────────────────────────────────────────────────── + test-biguint: + name: Test BigUint + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH run: | echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV - echo "$HOME/.pixi/bin" >> $GITHUB_PATH + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages + run: | + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_biguint.sh - - name: Activate virtualenv + # ── Test: BigInt10 ─────────────────────────────────────────────────────────── + test-bigint10: + name: Test BigInt10 + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH run: | - python3 -m venv $HOME/venv/ - . $HOME/venv/bin/activate - echo PATH=$PATH >> $GITHUB_ENV + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages + run: | + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_bigint10.sh - - name: Pixi install + # ── Test: Decimal128 ───────────────────────────────────────────────────────── + test-decimal128: + name: Test Decimal128 + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH run: | - pixi install + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages + run: | + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_decimal128.sh - - name: Build package + # ── Test: TomlMojo ─────────────────────────────────────────────────────────── + test-tomlmojo: + name: Test TomlMojo + runs-on: ubuntu-22.04 + timeout-minutes: 15 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH + run: | + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build packages run: | - pixi run mojo package src/decimo - pixi run mojo package src/tomlmojo - cp decimo.mojopkg tests/ - cp decimo.mojopkg benches/ - mv tomlmojo.mojopkg tests/ + pixi run mojo package src/decimo && cp decimo.mojopkg tests/ + pixi run mojo package src/tomlmojo && mv tomlmojo.mojopkg tests/ + - name: Run tests + run: bash ./tests/test_tomlmojo.sh + # ── Test: CLI ──────────────────────────────────────────────────────────────── + test-cli: + name: Test CLI + runs-on: ubuntu-22.04 + timeout-minutes: 15 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH + run: | + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build CLI binary + run: pixi run buildcli - name: Run tests + run: bash ./tests/test_cli.sh + + # ── Test: Python bindings ──────────────────────────────────────────────────── + test-python: + name: Test Python bindings + runs-on: ubuntu-22.04 + timeout-minutes: 15 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH run: | - bash ./tests/test_all.sh - bash ./tests/test_toml.sh + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Build & run Python tests + run: pixi run testpy - - name: Install pre-commit + # ── Format check ───────────────────────────────────────────────────────────── + format-check: + name: Format check + runs-on: ubuntu-22.04 + timeout-minutes: 10 + env: + DEBIAN_FRONTEND: noninteractive + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Add pixi to PATH run: | - pip install pre-commit - pre-commit install - - - name: Run pre-commit - run: | - pixi install - pre-commit run --all-files \ No newline at end of file + echo "PIXI_HOME=$HOME/.pixi" >> $GITHUB_ENV + echo "$HOME/.pixi/bin" >> $GITHUB_PATH + - name: pixi install + run: pixi install + - name: Install pre-commit + run: pip install pre-commit + - name: Run format check + run: pre-commit run --all-files \ No newline at end of file diff --git a/.gitignore b/.gitignore index 38bc221e..235d5309 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,8 @@ kgen.trace.json* /test*.py local # CLI binary -/decimo \ No newline at end of file +/decimo +# Python build artifacts +*.so +__pycache__/ +*.pyc \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ae3cdf6..802df318 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,5 +5,12 @@ repos: name: mojo-format entry: pixi run mojo format language: system - files: '\.(mojo|🔥|py)$' + files: '\.(mojo|🔥)$' + stages: [pre-commit] + + - id: ruff-format + name: ruff-format + entry: pixi run ruff format + language: system + files: '\.py$' stages: [pre-commit] diff --git a/LICENSE b/LICENSE index 261eeb9e..1ecc2e06 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Yuhao Zhu Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/plans/mojo4py.md b/docs/plans/mojo4py.md new file mode 100644 index 00000000..87dfe8ae --- /dev/null +++ b/docs/plans/mojo4py.md @@ -0,0 +1,447 @@ +# mojo4py: Exposing decimo to Python via Mojo Bindings + +> Initial date of planning: 2026-03-02 +> +> I use "mojo4py" as the name of this document - it refers to a package *written in Mojo* that is callable *from Python*. The inverse ("py4mojo") would be calling Python from Mojo, which decimo already does in some places. +> +> This name is pretty concise and descriptive. I will use the same "mojo4py" for Mojo Miji when discussing the Mojo-Python inter-operability. + +--- + +## 1. Summary + +Modular has introduced a beta mechanism that allows Mojo code to be exposed as a standard CPython extension module (`.so` / `.dylib`). This means a Python user can write `import decimo` and get access to Mojo-native `Decimal128`, `BigDecimal`, `BigInt`, and `BigUint` types at near-native speed, without rewriting anything in Python. + +**Feasibility verdict: Possible but non-trivial.** The main (no surprise) blocker is that decimo is a *packaged* Mojo library (`.mojopkg`), not a single `.mojo` file. The Mojo importer hook (the easy dev-time path) does not support custom import paths for non-stdlib Mojo packages. The `.so` build path (the distribution path) works fine. This means the developer workflow is slightly more manual, but distribution is fully viable. + +--- + +## 2. How the Mechanism Works (State of the Art) + +### 2.1 The Two Paths + +| Path | How | When to Use | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| **Source import hook** | `import mojo.importer` in Python, then `import mojo_module` (auto-compiles `.mojo` → `.so` into `__mojocache__/`) | Dev prototyping with single-file modules only | +| **Pre-built `.so`** | `mojo build mojo_module.mojo --emit shared-lib -o mojo_module.so` | Production, packages with dependencies, CI/CD | + +For decimo, **only the pre-built `.so` path is viable** because the binding code will `import decimo` (the `.mojopkg`), and the importer hook cannot resolve that path. + +### 2.2 The Binding Pattern + +Every exposed module needs a `PyInit_()` entry point: + +```mojo +from python import PythonObject +from python.bindings import PythonModuleBuilder +from os import abort + +@export +fn PyInit_decimo() -> PythonObject: + try: + var m = PythonModuleBuilder("decimo") + m.def_function[some_fn]("some_fn", docstring="...") + _ = m.add_type[BigDecimal]("BigDecimal") + .def_py_init[BigDecimal.py_init]() + .def_method[BigDecimal.py_add]("__add__") + # ...etc + return m.finalize() + except e: + abort(String("error creating decimo Python module: ", e)) +``` + +### 2.3 Type Binding Requirements + +For a Mojo struct to be bindable: + +| Feature | Required Trait | +| ----------------------------- | ------------------------------------------------------------------------------- | +| Bind the type at all | `Representable` | +| Custom `__init__` from Python | `Movable` + `def_py_init` | +| Default (no-arg) `__init__` | `Defaultable + Movable` + `def_init_defaultable` | +| Methods | `@staticmethod` with `py_self: PythonObject` or `self_ptr: UnsafePointer[Self]` | +| Static methods | `@staticmethod` with normal `PythonObject` args | +| Return Mojo value to Python | `PythonObject(alloc=value^)` (type must be registered first) | +| Accept Mojo value from Python | `py_obj.downcast_value_ptr[T]()` | + +### 2.4 Known Limitations (as of MAX 26.1) + +These are hard constraints today, expected to improve over time: + +1. **Max 6 `PythonObject` arguments** per bound function (use `def_py_function` workaround for variadic). +2. **No keyword-only arguments** (`fn foo(*, x: Int)` is unsupported). +3. **No native `*args`/`**kwargs`** syntax — must use `OwnedKwargsDict[PythonObject]` and `def_py_function` respectively. +4. **No computed properties** (getter/setter via `@property`). +5. **Non-stdlib Mojo package deps** are not resolvable by the importer hook — must build manually. +6. **Many stdlib types** do not yet implement `ConvertibleFromPython`, requiring manual conversion boilerplate. +7. **Methods must use non-standard self** (`py_self: PythonObject` or `UnsafePointer[Self]`) instead of normal `self`. +8. This is **Beta** — API will change. Do not stabilize the Python API until Modular marks this stable. + +--- + +## 3. Impact Analysis for decimo Types + +### 3.1 `BigDecimal` ★ Primary target + +- Arbitrary precision decimal — the most compelling type to expose to Python, directly competing with (and outperforming) Python's `decimal.Decimal`. +- Key operations to expose: `__init__`, `__add__`, `__sub__`, `__mul__`, `__truediv__`, `__mod__`, `__pow__`, `__neg__`, `__abs__`, `__repr__`, `__str__`, `__eq__`, `__lt__`, `__le__`, `__gt__`, `__ge__`, `sqrt`, `exp`, `ln`, `log10`, `round`. +- Constructors from Python `int`, `float`, `str` need manual dispatch in `py_init`. +- Requires `RoundingMode` to also be bound (see Section 3.4). +- **Complexity: Medium-High.** ~25-35 method bindings. + +### 3.2 `Decimal128` + +- Already `Stringable`, `Representable`, likely `Movable` — binding traits are probably satisfied. +- Fixed-precision (IEEE 754 decimal128) — useful as a faster, lower-memory alternative to `BigDecimal` when the precision fits. +- Exposes a nearly identical API surface to `BigDecimal`, so can share the same Python-side `.pyi` stub pattern. +- **Complexity: Medium.** Type is self-contained; ~20-30 method bindings. + +### 3.3 `BigInt` / `BigUint` + +- Heavy use of parameterized types internally; the public API surface is simpler. +- Python's `int` is arbitrary precision, so these directly compete with Python's native type — positioning matters. +- **Complexity: Medium.** + +### 3.4 Shared Infrastructure + +- `RoundingMode` enum-like struct needs to be either exposed as a Python class or mapped to Python string constants. +- Error types: Mojo `raises` becomes Python `Exception` automatically via the binding layer. +- `PythonObject` conversions: `String(py_obj)`, `Int(py=py_obj)` are supported for stdlib types. + +--- + +## 4. File Structure + +The binding code lives in a top-level `python/` directory at the project root, parallel to `src/`, `tests/`, `benches/`, and `docs/`. This keeps the Python distribution separate from the Mojo library source. + +```txt +python/ +├── decimo_module.mojo ← Mojo binding source (builds to _decimo.so) +├── _decimo.so ← compiled extension (gitignored) +├── decimo.py ← Python wrapper: Decimal class + BigDecimal alias +└── tests/ + └── test_decimo.py ← Python tests +``` + +The core Mojo library (`src/decimo/`) is not modified — all binding logic lives in `python/decimo_module.mojo` as free functions. + +### 4.1 Two-Layer Architecture + +Due to CPython slot limitations (see Phase 0 findings in Section 8), a two-layer pattern is used: + +1. **Mojo layer** (`decimo_module.mojo` → `_decimo.so`): Exposes `BigDecimal` with non-dunder method names (`add`, `sub`, `mul`, `to_string`, etc.). +2. **Python layer** (`decimo.py`): A thin `Decimal` wrapper class that delegates Python dunders (`__add__`, `__str__`, etc.) to the Mojo methods. + +This keeps the core `BigDecimal` struct unmodified and provides full Pythonic behavior (operators, `str()`, `repr()`, comparisons). + +### 4.2 The `Decimal` Alias + +The `Decimal` alias is set in `decimo.py` as the primary class name, with `BigDecimal = Decimal` for users who prefer the full name: + +```python +# python/decimo.py +from _decimo import BigDecimal as _BigDecimal + +class Decimal: + __slots__ = ("_inner",) + def __init__(self, value="0"): + self._inner = _BigDecimal(str(value)) + def __add__(self, other): + ... + # etc. + +BigDecimal = Decimal # alias +``` + +Python users import like: + +```python +from decimo import Decimal # preferred +from decimo import BigDecimal # also works, same class +``` + +--- + +## 5. Build System Integration (pixi.toml) + +Tasks added to `pixi.toml`: + +```toml +# python bindings (mojo4py) +pybuild = "pixi run mojo build python/decimo_module.mojo --emit shared-lib -I src -o python/_decimo.so" +pytest = "pixi run pybuild && pixi run python python/tests/test_decimo.py" +``` + +- `pixi run pybuild` — compiles the Mojo binding to `python/_decimo.so`. +- `pixi run pytest` — builds then runs the Python test suite. +- The `-I src` flag ensures `import decimo` in the Mojo binding resolves to `src/decimo/`. No need to pre-package `decimo.mojopkg`. + +--- + +## 6. Testing Strategy + +### 6.1 Test Layout + +```txt +python/ +└── tests/ + └── test_decimo.py ← Phase 0 tests (will be split per type later) +``` + +Tests live inside `python/tests/` — co-located with the binding code and `.so` file. This separation avoids mixing Python tests with the Mojo-native tests in `tests/`. + +### 6.2 Test Approach + +**Unit tests (pytest):** + +```python +# tests/python/test_bigdecimal.py +import pytest +from decimo import Decimal, BigDecimal + +def test_addition(): + a = Decimal("1.5") + b = Decimal("2.3") + assert str(a + b) == "3.8" + +def test_division_by_zero(): + with pytest.raises(Exception): + Decimal("1") / Decimal("0") + +def test_high_precision(): + a = Decimal("1") / Decimal("3") # 1/3 to full precision + assert str(a).startswith("0.333333") +``` + +**Alias tests:** + +```python +# tests/python/test_aliases.py +from decimo import Decimal, BigDecimal + +def test_decimal_is_bigdecimal(): + assert Decimal is BigDecimal + +def test_isinstance_works_both_ways(): + d = Decimal("1.5") + assert isinstance(d, Decimal) + assert isinstance(d, BigDecimal) # same type object +``` + +**Parity tests:** For each operation already tested in the Mojo test suite (e.g., `tests/bigdecimal/`), write a corresponding Python test with the same inputs/outputs. This double-checks that the binding layer doesn't silently change behavior. + +**Type and interop tests:** Verify that Python `int`, `float`, `str` arguments are accepted and correctly converted: + +```python +d = Decimal(42) # from Python int +d = Decimal(3.14) # from Python float +d = Decimal("1.23e5") # from Python str +``` + +**Exception propagation tests:** Verify that Mojo `raises` correctly surfaces as Python exceptions with meaningful messages. + +**Benchmark parity:** After the Python-callable layer is working, run a comparison of `decimo.Decimal` vs Python's `decimal.Decimal` to validate the performance proposition. + +### 6.3 CI Integration + +Add to CI pipeline: + +```bash +pixi run pytest +``` + +--- + +## 7. Distribution (Publishing) + +### 7.1 Distribution Formats + +| Format | How | Audience | +| ----------------------- | ------------------------------------------- | ------------------------ | +| **conda package** | pixi/conda-forge, ships `.so` per platform | Mojo/MAX ecosystem users | +| **PyPI wheel** | `python -m build`, platform-specific wheels | General Python users | +| **Source distribution** | Requires Mojo toolchain to build | Advanced / contributors | + +For PyPI, build platform-specific wheels. Since decimo currently targets `osx-arm64` and `linux-64`, this matches standard wheel tags: `cp313-cp313-macosx_11_0_arm64` and `cp313-cp313-linux_x86_64`. + +### 7.2 PyPI Wheel Build Process + +Use `scikit-build-core` or `meson-python` to integrate `mojo build` as the build backend step, or write a custom `build.py` script: + +```txt +python/ +├── pyproject.toml +├── build.py ← custom build step: invokes `mojo build --emit shared-lib` +├── MANIFEST.in +└── decimo/ + ├── __init__.py + ├── *.so ← built artifacts + └── *.pyi ← stubs +``` + +Example `pyproject.toml`: + +```toml +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "decimo" +version = "0.8.0" +description = "Arbitrary-precision decimal and integer types for Python, powered by Mojo" +requires-python = ">=3.13" +license = {text = "Apache-2.0"} + +[tool.setuptools.package-data] +decimo = ["*.so", "*.pyi", "py.typed"] +``` + +### 7.3 GitHub Actions CI/CD Sketch + +```yaml +# .github/workflows/python-wheel.yml +jobs: + build-wheels: + strategy: + matrix: + os: [macos-14, ubuntu-24.04] # arm64 mac, x86_64 linux + steps: + - uses: actions/checkout@v4 + - name: Install pixi + run: curl -fsSL https://pixi.sh/install.sh | sh + - name: Install dependencies + run: pixi install + - name: Build .so files + run: pixi run py_build + - name: Build wheel + run: cd python && pip wheel . -w dist/ + - uses: actions/upload-artifact@v4 + with: + path: python/dist/*.whl + publish: + needs: build-wheels + steps: + - uses: pypa/gh-action-pypi-publish@v1 +``` + +--- + +## 8. Roadmap + +### Phase 0 — Proof of Concept ✅ DONE (2026-03-02) + +- [x] Write binding for `BigDecimal.__init__(str)` and `BigDecimal.__str__`. +- [x] Manually build the `.so` with `mojo build --emit shared-lib -I src`. +- [x] Import from Python, confirm round-trip: `str(Decimal("1.23")) == "1.23"`. +- [x] Identify trait gaps (`Representable`, `Movable`, etc. — all satisfied). +- [x] Arithmetic: `+`, `-`, `*` work. Comparison: `==`, `<`, `<=`, `>`, `>=`, `!=` work. +- [x] `Decimal` alias (`Decimal is BigDecimal` → `True` in Python). +- [x] Large arbitrary-precision numbers work (38+ digit numbers). + +**Phase 0 findings & architecture decisions:** + +1. **Two-layer architecture is required.** `PythonTypeBuilder.def_method("__str__")` creates a dict entry but does NOT set the CPython `tp_str` type slot. Similarly, `def_method("__add__")` does NOT set `nb_add`. This means `str(d)` and `d + e` don't work — only `d.__str__()` and `d.__add__(e)` do. This is a CPython limitation for heap types created via C API: dunder methods must be registered as type slots, not just dict entries. + +2. **Solution: Mojo `.so` exposes non-dunder methods** (`to_string`, `add`, `sub`, `mul`, `neg`, `abs_`, `eq`, `lt`, `le`), and a **thin Python wrapper class** (`decimo.py`) delegates Python dunders to them. Overhead is negligible — one Python method call per operation, with all heavy math done in Mojo. + +3. **File layout for Phase 0:** + + ```txt + python/ + ├── decimo_module.mojo ← Mojo binding (builds to _decimo.so) + ├── _decimo.so ← compiled extension (PyInit__decimo, gitignored) + ├── decimo.py ← Python wrapper: Decimal class + BigDecimal alias + └── tests/ + └── test_decimo.py ← test script + ``` + +4. **Build command:** `pixi run pybuild` (= `mojo build python/decimo_module.mojo --emit shared-lib -I src -o python/_decimo.so`) + +5. **`def_py_init` signature:** `fn(out self: T, args: PythonObject, kwargs: PythonObject) raises` — works as a free function, does not need to be a `@staticmethod` on the struct itself. This means **zero modifications to the core BigDecimal struct** are needed for the binding. + +6. **`String(py_obj)` conversion:** `String(args[0])` works for Python `str` objects. For Python `int`/`float`, the caller must pass `str(value)` before calling the Mojo constructor — the Python wrapper handles this. + +### Phase 1 — BigDecimal Full Binding + +- [ ] Expose `RoundingMode` as Python constants or a Python enum. +- [ ] Expose all arithmetic operators: `__add__`, `__sub__`, `__mul__`, `__truediv__`, `__mod__`, `__pow__`, `__neg__`, `__abs__`. +- [ ] Expose comparison: `__eq__`, `__ne__`, `__lt__`, `__le__`, `__gt__`, `__ge__`. +- [ ] Expose constructors from `int`, `float`, `str`. +- [ ] Expose transcendentals: `sqrt`, `exp`, `ln`, `log10`. +- [ ] Expose `round(d, ndigits)` via `__round__`. +- [ ] Write Python test suite for `BigDecimal` (parity with `tests/bigdecimal/`). +- [ ] Add `pixi run py_build_bigdecimal` task. +- [ ] Write `.pyi` stub for `BigDecimal`. +- [ ] Set `Decimal = BigDecimal` alias in `python/decimo/__init__.py`. +- [ ] Add `test_aliases.py` to verify `Decimal is BigDecimal`. + +### Phase 2 — Decimal128 Binding + +- [ ] Expose `Decimal128`: same API surface as `BigDecimal` but fixed precision. +- [ ] Write `.pyi` stub for `Decimal128` (can largely mirror `bigdecimal.pyi`). +- [ ] Python tests with parity checks against `tests/decimal128/`. + +### Phase 3 — BigInt / BigUint Binding + +- [ ] Expose `BigInt`: arithmetic, comparison, `__int__`, `__str__`, `__hash__` (if feasible). +- [ ] Expose `BigUint` similarly. +- [ ] Handle `Int` ↔ `PythonObject` conversion for large Python integers (requires manual conversion logic since Python `int` is arbitrary precision). +- [ ] Python tests with parity checks. + +### Phase 4 — Packaging + Distribution + +- [ ] Create `python/` directory with `pyproject.toml` and `__init__.py`. +- [ ] Write `.pyi` stubs for all types. +- [ ] Add `py.typed` marker (PEP 561). +- [ ] Test `pip install` of the built wheel locally. +- [ ] Set up GitHub Actions for wheel builds (macOS arm64, Linux x86_64). +- [ ] Publish to PyPI (or TestPyPI first). + +### Phase 5 — Ergonomics + Stabilization + +- [ ] Add `__hash__` for use in dicts/sets. +- [ ] Add `__copy__` / `__deepcopy__`. +- [ ] Add `__reduce__` / `__reduce_ex__` for pickling. +- [ ] Handle `math.floor`, `math.ceil`, `math.trunc` via `__floor__`, `__ceil__`, `__trunc__`. +- [ ] Add `numbers.Number` ABC registration (soft-codes into Python's numeric tower). +- [ ] Implement `__format__` for f-string formatting. +- [ ] Benchmark against `decimal.Decimal` and publish results. +- [ ] Wait for Modular to stabilize the bindings API before final API freeze. + +--- + +## 9. Open Questions & Risks + +| Risk | Severity | Notes | +| --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| Beta API changes | High | Modular explicitly warns the bindings API will change. Pin to a specific MAX version until stable. | +| Mojo package deps in importer hook | Medium | Fully worked around via the manual `--emit shared-lib` build. No blocker. | +| Python `int` → Mojo `BigInt` conversion | Medium | Python's `int` is arbitrary-size. Need custom `ConvertibleFromPython` implementation or `def_py_function` workaround. | +| 6-argument limit | Low | Most arithmetic ops take ≤2 args. Might be hit by some `BigDecimal` rounding APIs. | +| No property support | Low | Use getter methods (`get_precision()`) as a workaround until properties land. | +| Platform support | Medium | Currently only `osx-arm64` and `linux-64`. Windows is not yet a Mojo target. | +| ABI compatibility | Medium | The `.so` is linked against a specific MAX/Python version. Wheels must be version-specific. | + +--- + +## 10. Quick-Start + +Build and test with two commands: + +```bash +pixi run pybuild # Compiles python/decimo_module.mojo → python/_decimo.so +pixi run pytest # Builds, then runs python/tests/test_decimo.py +``` + +From Python: + +```python +from decimo import Decimal + +a = Decimal("1.5") +b = Decimal("2.3") +print(a + b) # 3.8 +print(repr(a)) # Decimal("1.5") +assert Decimal("1") < Decimal("2") # True +``` diff --git a/pixi.lock b/pixi.lock index 111ff20b..82bdb56d 100644 --- a/pixi.lock +++ b/pixi.lock @@ -54,6 +54,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.2-h40fa522_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda @@ -100,6 +101,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyzmq-27.1.0-py312h022ad19_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.2-h279115b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda @@ -806,6 +808,33 @@ packages: license_family: GPL size: 313930 timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.2-h40fa522_0.conda + noarch: python + sha256: e0403324ac0de06f51c76ae2a4671255d48551a813d1f1dc03bd4db7364604f0 + md5: 8dec25bd8a94496202f6f3c9085f2ad3 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 9284016 + timestamp: 1771570005837 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.2-h279115b_0.conda + noarch: python + sha256: 80f93811d26e58bf5a635d1034cba8497223c2bf9efa2a67776a18903e40944a + md5: a52cf978daa5290b1414d3bba6b6ea0b + depends: + - python + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 8415205 + timestamp: 1771570140500 - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d md5: 3339e3b65d58accf4ca4fb8748ab16b3 diff --git a/pixi.toml b/pixi.toml index 9a6ba7f2..4284102c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -1,5 +1,5 @@ [workspace] -authors = ["ZHU Yuhao 朱宇浩 "] +authors = ["ZHU Yuhao (朱宇浩) "] channels = ["https://conda.modular.com/max-nightly", "https://conda.modular.com/max", "https://repo.prefix.dev/modular-community", "conda-forge"] description = "An arbitrary-precision decimal and integer library for Mojo" license = "Apache-2.0" @@ -9,45 +9,79 @@ readme = "README.md" version = "0.8.0" [dependencies] -argmojo = ">=0.1.0,<0.2" -mojo = "==0.26.1" -python = ">=3.13,<3.14" +argmojo = ">=0.1.0,<0.2" # CLI argument parsing for the Decimo calculator +mojo = "==0.26.1" # Mojo language compiler and runtime +python = ">=3.13,<3.14" # For Python bindings and tests +ruff = ">=0.9,<1" # Python code formatter [tasks] # format the code -format = "pixi run mojo format ./src && pixi run mojo format ./benches && pixi run mojo format ./tests && pixi run mojo format ./docs" +format = """pixi run mojo format ./src \ +&&pixi run mojo format ./python \ +&&pixi run mojo format ./benches \ +&&pixi run mojo format ./tests \ +&&pixi run mojo format ./docs \ +&&pixi run ruff format ./python""" + +# doc +doc = """clear && pixi run mojo doc \ +--diagnose-missing-doc-strings --validate-doc-strings src/decimo""" # compile the package p = "clear && pixi run package" -package = "pixi run format && pixi run package_decimo && pixi run package_tomlmojo" -package_decimo = "pixi run mojo package src/decimo && cp decimo.mojopkg tests/ && cp decimo.mojopkg benches/ && rm decimo.mojopkg" -package_tomlmojo = "pixi run mojo package src/tomlmojo && cp tomlmojo.mojopkg tests/ && cp tomlmojo.mojopkg benches/ && rm tomlmojo.mojopkg" +package = """pixi run format \ +&& pixi run package_decimo \ +&& pixi run package_tomlmojo""" +package_decimo = """pixi run mojo package src/decimo \ +&& cp decimo.mojopkg tests/ \ +&& cp decimo.mojopkg benches/ \ +&& rm decimo.mojopkg""" +package_tomlmojo = """pixi run mojo package src/tomlmojo \ +&& cp tomlmojo.mojopkg tests/ \ +&& cp tomlmojo.mojopkg benches/ \ +&& rm tomlmojo.mojopkg""" # clean the package files in tests folder c = "clear && pixi run clean" -clean = "rm tests/decimo.mojopkg && rm benches/decimo.mojopkg && rm tests/tomlmojo.mojopkg && rm benches/tomlmojo.mojopkg" +clean = """rm tests/decimo.mojopkg && \ +rm benches/decimo.mojopkg && \ +rm tests/tomlmojo.mojopkg && \ +rm benches/tomlmojo.mojopkg""" # tests (use the mojo testing tool) -b = "clear && pixi run package && bash ./tests/test_big.sh" -b2 = "clear && pixi run package && bash ./tests/test_binary.sh" -t = "clear && pixi run package && bash ./tests/test_all.sh" +t = "clear && pixi run test" +tdecimo = "clear && pixi run testdecimo" test = "pixi run package && bash ./tests/test_all.sh" -test_toml = "pixi run package && bash ./tests/test_toml.sh" -ttoml = "clear && pixi run package && bash ./tests/test_toml.sh" +testdecimo = "pixi run package && bash ./tests/test_decimo.sh" +testtoml = "pixi run package && bash ./tests/test_tomlmojo.sh" +ttoml = "clear && pixi run testtoml" # bench bench = "pixi run package && bash benches/run_bench.sh" # bench with debug assertions enabled -bdec_debug = "clear && pixi run package && cd benches/bigdecimal && pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. && pixi run clean" -bint_debug = "clear && pixi run package && cd benches/bigint && pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. && pixi run clean" -buint_debug = "clear && pixi run package && cd benches/biguint && pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. && pixi run clean" -dec_debug = "clear && pixi run package && cd benches/decimal128 && pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. && pixi run clean" - -# doc -doc = "clear && pixi run mojo doc -o docs/doc.json --diagnose-missing-doc-strings --validate-doc-strings src/decimo" +bdec_debug = """clear && pixi run package && cd benches/bigdecimal \ +&& pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. \ +&& pixi run clean""" +bint_debug = """clear && pixi run package && cd benches/bigint \ +&& pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. \ +&& pixi run clean""" +buint_debug = """clear && pixi run package && cd benches/biguint \ +&& pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. \ +&& pixi run clean""" +dec_debug = """clear && pixi run package && cd benches/decimal128 \ +&& pixi run mojo run -I ../ -D ASSERT=all bench.mojo && cd ../.. \ +&& pixi run clean""" # cli calculator -build = "pixi run mojo build -I src -I src/cli -o decimo src/cli/main.mojo" -tcli = "clear && bash tests/test_cli.sh" -test_cli = "bash tests/test_cli.sh" +bcli = "clear && pixi run buildcli" +buildcli = "pixi run mojo build -I src -I src/cli -o decimo src/cli/main.mojo" +tcli = "clear && pixi run testcli" +testcli = "bash tests/test_cli.sh" + +# python bindings (mojo4py) +bpy = "clear && pixi run buildpy" +buildpy = """pixi run mojo build python/decimo_module.mojo \ +--emit shared-lib -I src -o python/_decimo.so""" +testpy = "pixi run buildpy && pixi run python python/tests/test_decimo.py" +tpy = "clear && pixi run testpy" diff --git a/python/_decimo.pyi b/python/_decimo.pyi new file mode 100644 index 00000000..8e4c0603 --- /dev/null +++ b/python/_decimo.pyi @@ -0,0 +1,18 @@ +"""Type stubs for the _decimo Mojo extension module. + +This file tells Pylance/mypy the interface of the compiled _decimo.so. +""" + +class BigDecimal: + def __init__(self, value: str) -> None: ... + def to_string(self) -> str: ... + def to_repr(self) -> str: ... + def add(self, other: BigDecimal) -> BigDecimal: ... + def sub(self, other: BigDecimal) -> BigDecimal: ... + def mul(self, other: BigDecimal) -> BigDecimal: ... + def div(self, other: BigDecimal) -> BigDecimal: ... + def neg(self) -> BigDecimal: ... + def abs_(self) -> BigDecimal: ... + def eq(self, other: BigDecimal) -> bool: ... + def lt(self, other: BigDecimal) -> bool: ... + def le(self, other: BigDecimal) -> bool: ... diff --git a/python/decimo.py b/python/decimo.py new file mode 100644 index 00000000..8ec75594 --- /dev/null +++ b/python/decimo.py @@ -0,0 +1,144 @@ +"""decimo: Arbitrary-precision decimal arithmetic for Python, powered by Mojo. + +Usage: + from decimo import Decimal + + a = Decimal("1.5") + b = Decimal("2.3") + print(a + b) # 3.8 +""" + +from _decimo import BigDecimal as _BigDecimal + + +class Decimal: + """Arbitrary-precision decimal number. + + This is a thin Python wrapper around decimo's Mojo-native BigDecimal type. + All heavy arithmetic is performed in Mojo at near-native speed. + """ + + __slots__ = ("_inner",) + + def __init__(self, value="0"): + if isinstance(value, Decimal): + self._inner = value._inner + elif isinstance(value, _BigDecimal): + self._inner = value + else: + self._inner = _BigDecimal(str(value)) + + # --- String --- + + def __str__(self): + return self._inner.to_string() + + def __repr__(self): + return self._inner.to_repr() + + # --- Arithmetic --- + + def __add__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + result = Decimal.__new__(Decimal) + result._inner = self._inner.add(other._inner) + return result + + def __radd__(self, other): + return Decimal(other).__add__(self) + + def __sub__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + result = Decimal.__new__(Decimal) + result._inner = self._inner.sub(other._inner) + return result + + def __rsub__(self, other): + return Decimal(other).__sub__(self) + + def __mul__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + result = Decimal.__new__(Decimal) + result._inner = self._inner.mul(other._inner) + return result + + def __rmul__(self, other): + return Decimal(other).__mul__(self) + + def __truediv__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + result = Decimal.__new__(Decimal) + result._inner = self._inner.div(other._inner) + return result + + def __neg__(self): + result = Decimal.__new__(Decimal) + result._inner = self._inner.neg() + return result + + def __abs__(self): + result = Decimal.__new__(Decimal) + result._inner = self._inner.abs_() + return result + + def __pos__(self): + return self # no-op + + # --- Comparison --- + + def __eq__(self, other): + if not isinstance(other, Decimal): + try: + other = Decimal(other) + except Exception: + return NotImplemented + return self._inner.eq(other._inner) + + def __lt__(self, other): + if not isinstance(other, Decimal): + try: + other = Decimal(other) + except Exception: + return NotImplemented + return self._inner.lt(other._inner) + + def __le__(self, other): + if not isinstance(other, Decimal): + try: + other = Decimal(other) + except Exception: + return NotImplemented + return self._inner.le(other._inner) + + def __gt__(self, other): + if not isinstance(other, Decimal): + try: + other = Decimal(other) + except Exception: + return NotImplemented + return not self._inner.le(other._inner) + + def __ge__(self, other): + if not isinstance(other, Decimal): + try: + other = Decimal(other) + except Exception: + return NotImplemented + return not self._inner.lt(other._inner) + + def __ne__(self, other): + eq_result = self.__eq__(other) + if eq_result is NotImplemented: + return NotImplemented + return not eq_result + + def __bool__(self): + return str(self) not in ("0", "-0") + + +# Also expose as BigDecimal for users who prefer the full name +BigDecimal = Decimal diff --git a/python/decimo_module.mojo b/python/decimo_module.mojo new file mode 100644 index 00000000..6d72b24c --- /dev/null +++ b/python/decimo_module.mojo @@ -0,0 +1,165 @@ +# ===----------------------------------------------------------------------=== # +# decimo-python +# Mojo bindings for Python, exposing the BigDecimal type and basic operations. +# Because the Mojo-Python interop is still in early stages, this module is +# mainly an experiment to test the capabilities and ergonomics of the bindings, +# and to give me some experience writing Mojo Miji (https://mojo-lang.com/miji). +# +# I followed the official guide for writing a Mojo module for Python: +# https://docs.modular.com/mojo/manual/python/mojo-from-python +# ===----------------------------------------------------------------------=== # + +from python import PythonObject +from python.bindings import PythonModuleBuilder +from os import abort + +from decimo import BigDecimal + + +# ===----------------------------------------------------------------------=== # +# PyInit entry point +# ===----------------------------------------------------------------------=== # + + +@export +fn PyInit__decimo() -> PythonObject: + try: + var m = PythonModuleBuilder("_decimo") + _ = ( + m.add_type[BigDecimal]("BigDecimal") + .def_py_init[bigdecimal_py_init]() + .def_method[bigdecimal_to_string]("to_string") + .def_method[bigdecimal_to_repr]("to_repr") + .def_method[bigdecimal_add]("add") + .def_method[bigdecimal_sub]("sub") + .def_method[bigdecimal_mul]("mul") + .def_method[bigdecimal_div]("div") + .def_method[bigdecimal_neg]("neg") + .def_method[bigdecimal_abs]("abs_") + .def_method[bigdecimal_eq]("eq") + .def_method[bigdecimal_lt]("lt") + .def_method[bigdecimal_le]("le") + ) + return m.finalize() + except e: + abort(String("error creating _decimo Python module: ", e)) + + +# ===----------------------------------------------------------------------=== # +# Binding functions +# ===----------------------------------------------------------------------=== # + + +fn bigdecimal_py_init( + out self: BigDecimal, args: PythonObject, kwargs: PythonObject +) raises: + """Construct a BigDecimal from a single argument (string, int, or float). + + Usage from Python: + Decimal("3.14") + Decimal(42) + Decimal(3.14) # via str() conversion + """ + if len(args) != 1: + raise Error( + "Decimal() takes exactly 1 argument (" + + String(len(args)) + + " given)" + ) + # Convert any Python object to its string representation, then construct. + # This handles str, int, and float gracefully. + var s = String(args[0]) + self = BigDecimal(s) + + +fn bigdecimal_to_string(py_self: PythonObject) raises -> PythonObject: + """Return the decimal as a plain string, e.g. '3.14'.""" + var ptr = py_self.downcast_value_ptr[BigDecimal]() + return PythonObject(ptr[].__str__()) + + +fn bigdecimal_to_repr(py_self: PythonObject) raises -> PythonObject: + """Return the repr string, e.g. 'Decimal(\"3.14\")'.""" + var ptr = py_self.downcast_value_ptr[BigDecimal]() + return PythonObject('Decimal("' + ptr[].__str__() + '")') + + +fn bigdecimal_add( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self + other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + var result = self_ptr[] + other_ptr[] + return PythonObject(alloc=result^) + + +fn bigdecimal_sub( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self - other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + var result = self_ptr[] - other_ptr[] + return PythonObject(alloc=result^) + + +fn bigdecimal_mul( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self * other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + var result = self_ptr[] * other_ptr[] + return PythonObject(alloc=result^) + + +fn bigdecimal_div( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self / other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + var result = self_ptr[] / other_ptr[] + return PythonObject(alloc=result^) + + +fn bigdecimal_neg(py_self: PythonObject) raises -> PythonObject: + """Return -self.""" + var ptr = py_self.downcast_value_ptr[BigDecimal]() + var result = -(ptr[]) + return PythonObject(alloc=result^) + + +fn bigdecimal_abs(py_self: PythonObject) raises -> PythonObject: + """Return abs(self).""" + var ptr = py_self.downcast_value_ptr[BigDecimal]() + var result = abs(ptr[]) + return PythonObject(alloc=result^) + + +fn bigdecimal_eq( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self == other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + return PythonObject(self_ptr[] == other_ptr[]) + + +fn bigdecimal_lt( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self < other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + return PythonObject(self_ptr[] < other_ptr[]) + + +fn bigdecimal_le( + py_self: PythonObject, other: PythonObject +) raises -> PythonObject: + """Return self <= other.""" + var self_ptr = py_self.downcast_value_ptr[BigDecimal]() + var other_ptr = other.downcast_value_ptr[BigDecimal]() + return PythonObject(self_ptr[] <= other_ptr[]) diff --git a/python/tests/test_decimo.py b/python/tests/test_decimo.py new file mode 100644 index 00000000..826b6726 --- /dev/null +++ b/python/tests/test_decimo.py @@ -0,0 +1,128 @@ +"""Verify BigDecimal round-trip through Mojo-Python bindings. + +Cross-validates against Python's standard library decimal.Decimal where applicable. +""" + +import decimal +import operator +from pathlib import Path +import sys + +# Add python/ directory to sys.path so `import decimo` resolves to decimo.py +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import decimo + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def check_arith(op_name, a_str, b_str, op): + """Compare an arithmetic op between decimo and stdlib.""" + d_result = str(op(decimo.Decimal(a_str), decimo.Decimal(b_str))) + s_result = str(op(decimal.Decimal(a_str), decimal.Decimal(b_str))) + assert d_result == s_result, ( + f"{op_name}({a_str}, {b_str}): decimo={d_result!r}, stdlib={s_result!r}" + ) + return d_result + + +def check_unary(op_name, a_str, op): + """Compare a unary op between decimo and stdlib.""" + d_result = str(op(decimo.Decimal(a_str))) + s_result = str(op(decimal.Decimal(a_str))) + assert d_result == s_result, ( + f"{op_name}({a_str}): decimo={d_result!r}, stdlib={s_result!r}" + ) + return d_result + + +def check_cmp(op_name, a_str, b_str, op): + """Compare a comparison op between decimo and stdlib.""" + d_result = op(decimo.Decimal(a_str), decimo.Decimal(b_str)) + s_result = op(decimal.Decimal(a_str), decimal.Decimal(b_str)) + assert d_result == s_result, ( + f"{op_name}({a_str}, {b_str}): decimo={d_result}, stdlib={s_result}" + ) + return d_result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +print("=== decimo mojo4py Phase 0 ===") +print() + +# --- Alias test --- +assert decimo.Decimal is decimo.BigDecimal, "Decimal should be BigDecimal" +print("[PASS] Decimal is BigDecimal") + +# --- Construction / round-trip (cross-validated) --- +for s in [ + "3.14159265358979323846", + "123456789.987654321", + "42", + "0", + "-7.5", + "99999999999999999999999999999999999999.123456789", +]: + assert str(decimo.Decimal(s)) == str(decimal.Decimal(s)), ( + f"Round-trip mismatch for {s!r}" + ) +print("[PASS] Round-trip (cross-validated with stdlib decimal)") + +# --- repr --- +d = decimo.Decimal("3.14159265358979323846") +print(f"[PASS] repr = {repr(d)}") +print() + +# --- Arithmetic (cross-validated) --- +pairs = [ + ("1.5", "2.3"), + ("100", "0.001"), + ("0", "999"), + ("-3.5", "2.5"), + ("1", "3"), +] + + +for a_str, b_str in pairs: + r = check_arith("add", a_str, b_str, operator.add) + print(f"[PASS] {a_str} + {b_str} = {r} (matches stdlib)") + r = check_arith("sub", a_str, b_str, operator.sub) + print(f"[PASS] {a_str} - {b_str} = {r} (matches stdlib)") + r = check_arith("mul", a_str, b_str, operator.mul) + print(f"[PASS] {a_str} * {b_str} = {r} (matches stdlib)") + +# --- Unary (cross-validated) --- +for v in ["1.5", "-1.5", "0", "99.99"]: + if v != "0": # decimo gives "-0" for neg(0); stdlib gives "0" — skip cross-check + r = check_unary("neg", v, operator.neg) + print(f"[PASS] -{v} = {r} (matches stdlib)") + r = check_unary("abs", v, operator.abs) + print(f"[PASS] abs({v}) = {r} (matches stdlib)") +print() + +# --- Comparison (cross-validated) --- +cmp_pairs = [ + ("1.5", "1.5"), + ("1.5", "2.3"), + ("2.3", "1.5"), + ("-1", "1"), + ("0", "0"), + ("100", "99.999"), +] + +for a_str, b_str in cmp_pairs: + check_cmp("eq", a_str, b_str, operator.eq) + check_cmp("ne", a_str, b_str, operator.ne) + check_cmp("lt", a_str, b_str, operator.lt) + check_cmp("le", a_str, b_str, operator.le) + check_cmp("gt", a_str, b_str, operator.gt) + check_cmp("ge", a_str, b_str, operator.ge) +print("[PASS] All comparisons (cross-validated with stdlib decimal)") +print() + +print("=== All Phase 0 tests passed! ===") diff --git a/tests/cli/test_evaluator.mojo b/tests/cli/test_evaluator.mojo index 00ef1375..651307cb 100644 --- a/tests/cli/test_evaluator.mojo +++ b/tests/cli/test_evaluator.mojo @@ -314,7 +314,7 @@ fn test_ln_e_is_one() raises: fn test_cbrt_27() raises: - """cbrt(27) ≈ 3.""" + """Tests cbrt(27) ≈ 3.""" var result = String(evaluate("cbrt(27)")) testing.assert_true( result == "3" or result.startswith("3."), @@ -323,17 +323,17 @@ fn test_cbrt_27() raises: fn test_log10_1000() raises: - """log10(1000) = 3.""" + """Tests log10(1000) = 3.""" testing.assert_equal(String(evaluate("log10(1000)")), "3", "log10(1000)") fn test_log10_1() raises: - """log10(1) = 0.""" + """Tests log10(1) = 0.""" testing.assert_equal(String(evaluate("log10(1)")), "0", "log10(1)") fn test_log_base_2() raises: - """log(8, 2) ≈ 3.""" + """Tests log(8, 2) ≈ 3.""" var result = String(evaluate("log(8, 2)")) testing.assert_true( result == "3" or result.startswith("3."), @@ -342,7 +342,7 @@ fn test_log_base_2() raises: fn test_log_base_100() raises: - """log(1000000, 100) ≈ 3.""" + """Tests log(1000000, 100) ≈ 3.""" var result = String(evaluate("log(1000000, 100)")) testing.assert_true( result == "3" or result.startswith("3."), @@ -351,17 +351,17 @@ fn test_log_base_100() raises: fn test_cos_0() raises: - """cos(0) = 1.""" + """Tests cos(0) = 1.""" testing.assert_equal(String(evaluate("cos(0)")), "1", "cos(0)") fn test_tan_0() raises: - """tan(0) = 0.""" + """Tests tan(0) = 0.""" testing.assert_equal(String(evaluate("tan(0)")), "0", "tan(0)") fn test_cot_pi_over_4() raises: - """cot(pi/4) is very close to 1.""" + """Tests cot(pi/4) is very close to 1.""" var result = String(evaluate("cot(pi/4)", precision=20)) testing.assert_true( result == "1" or result.startswith("1.") or result.startswith("0.9999"), @@ -370,7 +370,7 @@ fn test_cot_pi_over_4() raises: fn test_csc_pi_over_2() raises: - """csc(pi/2) is very close to 1.""" + """Tests csc(pi/2) is very close to 1.""" var result = String(evaluate("csc(pi/2)", precision=20)) testing.assert_true( result == "1" or result.startswith("1.") or result.startswith("0.9999"), diff --git a/tests/test_all.sh b/tests/test_all.sh index afdfcc75..c6864869 100644 --- a/tests/test_all.sh +++ b/tests/test_all.sh @@ -1,13 +1,6 @@ #!/bin/bash -set -e # Exit immediately if any command fails +set -e -for dir in biguint bigint10 bigdecimal bigint decimal128; do - for f in tests/$dir/*.mojo; do - pixi run mojo run -I src -D ASSERT=all "$f" - done -done - -# CLI calculator tests (need both src and cli on the include path) -for f in tests/cli/*.mojo; do - pixi run mojo run -I src -I src/cli -D ASSERT=all "$f" -done \ No newline at end of file +bash ./tests/test_decimo.sh +bash ./tests/test_tomlmojo.sh +bash ./tests/test_cli.sh diff --git a/tests/test_big.sh b/tests/test_big.sh deleted file mode 100644 index 81489edb..00000000 --- a/tests/test_big.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e # Exit immediately if any command fails - -for dir in biguint bigint10 bigdecimal; do - for f in tests/$dir/*.mojo; do - pixi run mojo run -I src -D ASSERT=all "$f" - done -done \ No newline at end of file diff --git a/tests/test_bigdecimal.sh b/tests/test_bigdecimal.sh new file mode 100755 index 00000000..d62e5847 --- /dev/null +++ b/tests/test_bigdecimal.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +for f in tests/bigdecimal/*.mojo; do + pixi run mojo run -I src -D ASSERT=all "$f" +done diff --git a/tests/test_bigint.sh b/tests/test_bigint.sh new file mode 100755 index 00000000..ac92ac07 --- /dev/null +++ b/tests/test_bigint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +for f in tests/bigint/*.mojo; do + pixi run mojo run -I src -D ASSERT=all "$f" +done diff --git a/tests/test_bigint10.sh b/tests/test_bigint10.sh new file mode 100755 index 00000000..57856d28 --- /dev/null +++ b/tests/test_bigint10.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +for f in tests/bigint10/*.mojo; do + pixi run mojo run -I src -D ASSERT=all "$f" +done diff --git a/tests/test_biguint.sh b/tests/test_biguint.sh new file mode 100755 index 00000000..bee5f866 --- /dev/null +++ b/tests/test_biguint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +for f in tests/biguint/*.mojo; do + pixi run mojo run -I src -D ASSERT=all "$f" +done diff --git a/tests/test_binary.sh b/tests/test_binary.sh deleted file mode 100644 index 1fa68b01..00000000 --- a/tests/test_binary.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e # Exit immediately if any command fails - -for dir in bigint; do - for f in tests/$dir/*.mojo; do - pixi run mojo run -I src -D ASSERT=all "$f" - done -done \ No newline at end of file diff --git a/tests/test_decimal128.sh b/tests/test_decimal128.sh new file mode 100755 index 00000000..d2a4d05f --- /dev/null +++ b/tests/test_decimal128.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +for f in tests/decimal128/*.mojo; do + pixi run mojo run -I src -D ASSERT=all "$f" +done diff --git a/tests/test_decimo.sh b/tests/test_decimo.sh new file mode 100644 index 00000000..6ab2fab5 --- /dev/null +++ b/tests/test_decimo.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +bash ./tests/test_bigdecimal.sh +bash ./tests/test_bigint.sh +bash ./tests/test_biguint.sh +bash ./tests/test_bigint10.sh +bash ./tests/test_decimal128.sh diff --git a/tests/test_toml.sh b/tests/test_tomlmojo.sh similarity index 67% rename from tests/test_toml.sh rename to tests/test_tomlmojo.sh index b86fd762..ada89a54 100755 --- a/tests/test_toml.sh +++ b/tests/test_tomlmojo.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e # Exit immediately if any command fails +set -e for f in tests/tomlmojo/*.mojo; do pixi run mojo run -I src -D ASSERT=all "$f"