From 5f02c04279966c30b4ed0f8d92e1072b0dff9fbe Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 17:03:04 +0100 Subject: [PATCH 1/6] Prepare for mojo library in python --- LICENSE | 2 +- docs/plans/mojo4py.md | 526 ++++++++++++++++++++++++++++++++++++++++++ pixi.toml | 2 +- 3 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 docs/plans/mojo4py.md 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..f4c1be51 --- /dev/null +++ b/docs/plans/mojo4py.md @@ -0,0 +1,526 @@ +# mojo4py: Exposing decimo to Python via Mojo Bindings + +> 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 under a new `src/decimo/python/` sub-package, separate from the core implementation. This keeps concerns clean and the core library free of Python-specific boilerplate. + +```txt +src/ +└── decimo/ + ├── __init__.mojo (existing) + ├── decimal128/ (existing) + ├── bigdecimal/ (existing) + ├── bigint/ (existing) + ├── biguint/ (existing) + ├── errors.mojo (existing) + ├── prelude.mojo (existing) + ├── rounding_mode.mojo (existing) + └── python/ ← NEW sub-package + ├── __init__.mojo ← top-level PyInit_decimo() entry point + ├── bind_decimal128.mojo + ├── bind_bigdecimal.mojo + ├── bind_bigint.mojo + ├── bind_biguint.mojo + └── helpers.mojo ← shared conversion helpers (PythonObject ↔ RoundingMode, etc.) +``` + +The `python/__init__.mojo` contains the single `@export fn PyInit_decimo()` that calls each `bind_*` file's registration function, then calls `m.finalize()`. + +Alternatively, for initial simplicity, expose each type as its own module: + +```txt +src/decimo/python/ + ├── decimal128_module.mojo → builds to decimal128.so + ├── bigdecimal_module.mojo → builds to bigdecimal.so + ├── bigint_module.mojo → builds to bigint.so + └── biguint_module.mojo → builds to biguint.so +``` + +**Recommendation:** Start with separate modules per type (easier to iterate, easier to test in isolation), then merge into a single `decimo` module once the API stabilizes. + +### 4.1 Python-side Wrapper Package (`python/decimo/`) + +A thin Python wrapper package provides: + +- Type stubs (`.pyi` files) for IDE autocomplete and mypy/pyright support +- Pythonic re-exports and documentation +- The canonical `Decimal` alias (see below) +- Optional pure-Python fallback for platforms where the `.so` is unavailable + +```txt +python/ +└── decimo/ ← Python package (installable via pip/conda) + ├── __init__.py ← imports the .so, re-exports, and sets Decimal alias + ├── bigdecimal.pyi ← type stubs + ├── decimal128.pyi + ├── bigint.pyi + ├── biguint.pyi + └── py.typed ← PEP 561 marker +``` + +### 4.2 The `Decimal` Alias + +Set the alias in the Python wrapper `__init__.py`, not in the Mojo binding layer: + +```python +# python/decimo/__init__.py +from ._decimo import BigDecimal, Decimal128, BigInt, BigUint # .so symbols + +# Expose Decimal as a friendly alias for BigDecimal. +# Because it's assignment, not subclassing, both names refer to the +# *exact same type object*: isinstance(d, Decimal) == isinstance(d, BigDecimal). +Decimal = BigDecimal + +__all__ = ["Decimal", "BigDecimal", "Decimal128", "BigInt", "BigUint"] +``` + +Python users can then use either name interchangeably: + +```python +from decimo import Decimal # preferred, familiar name +from decimo import BigDecimal # also works, same object + +d = Decimal("1.23456789") +print(isinstance(d, BigDecimal)) # True — same type +``` + +**Stub file:** The `.pyi` stub should document both names: + +```python +# python/decimo/bigdecimal.pyi +class BigDecimal: + def __init__(self, value: int | float | str) -> None: ... + def __add__(self, other: BigDecimal) -> BigDecimal: ... + # ... + +Decimal = BigDecimal # alias +``` + +--- + +## 5. Build System Integration (pixi.toml) + +New tasks to add to `pixi.toml`: + +```toml +# Build Python extension modules (.so files) +py_build_bigdecimal = """ + pixi run mojo build src/decimo/python/bigdecimal_module.mojo \ + --emit shared-lib \ + -I src \ + -o python/decimo/_decimo_bigdecimal.so +""" +py_build_decimal128 = """ + pixi run mojo build src/decimo/python/decimal128_module.mojo \ + --emit shared-lib \ + -I src \ + -o python/decimo/_decimo_decimal128.so +""" +py_build_bigint = """ + pixi run mojo build src/decimo/python/bigint_module.mojo \ + --emit shared-lib \ + -I src \ + -o python/decimo/_decimo_bigint.so +""" +py_build = "pixi run py_build_bigdecimal && pixi run py_build_decimal128 && pixi run py_build_bigint" + +# Run Python tests +py_test = "pixi run py_build && python -m pytest tests/python/ -v" + +# Build + install locally for interactive testing +py_install = "pixi run py_build && pip install -e python/ --no-build-isolation" +``` + +Key point: the `-I src` flag ensures `import decimo` in the binding Mojo file resolves to `src/decimo/`. I do **not** need to pre-package `decimo.mojopkg` for the binding build — the source directory import works directly with `mojo build`. + +--- + +## 6. Testing Strategy + +### 6.1 Test Layout + +```txt +tests/ +├── python/ ← NEW +│ ├── test_bigdecimal.py ← primary +│ ├── test_decimal128.py +│ ├── test_bigint.py +│ ├── test_biguint.py +│ ├── test_aliases.py ← verifies Decimal is BigDecimal +│ └── conftest.py ← shared fixtures, e.g. pre-built .so path +└── test_all.sh (existing, Mojo-native 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 the CI pipeline (if one exists) or to `test_all.sh`: + +```bash +# In tests/test_all.sh or a new tests/test_python.sh +pixi run py_build +python -m pytest tests/python/ -v --tb=short +``` + +--- + +## 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 + +- [ ] Write binding for a single function: `BigDecimal.__init__(str)` and `BigDecimal.__str__`. +- [ ] Manually build the `.so` with `mojo build --emit shared-lib -I src`. +- [ ] Import from Python, confirm round-trip: `str(BigDecimal("1.23")) == "1.23"`. +- [ ] Identify trait gaps (`Representable`, `Movable`, etc., should be fine). + +### 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 Skeleton + +To start the proof of concept, create `src/decimo/python/bigdecimal_module.mojo`: + +```mojo +from python import PythonObject +from python.bindings import PythonModuleBuilder +from os import abort +from decimo.bigdecimal import BigDecimal + +@export +fn PyInit_bigdecimal() -> PythonObject: + try: + var m = PythonModuleBuilder("bigdecimal") + _ = m.add_type[BigDecimal]("BigDecimal") + .def_py_init[BigDecimal.py_init]() + .def_method[BigDecimal.py_add]("__add__") + .def_method[BigDecimal.py_sub]("__sub__") + .def_method[BigDecimal.py_mul]("__mul__") + .def_method[BigDecimal.py_truediv]("__truediv__") + .def_method[BigDecimal.py_str]("__str__") + .def_method[BigDecimal.py_repr]("__repr__") + return m.finalize() + except e: + abort(String("error creating bigdecimal Python module: ", e)) +``` + +Then build it: + +```bash +mojo build src/decimo/python/bigdecimal_module.mojo \ + --emit shared-lib \ + -I src \ + -o bigdecimal.so +``` + +Then in Python: + +```python +import bigdecimal +d = bigdecimal.BigDecimal("3.14159265358979323846") +print(d) # 3.14159265358979323846 +print(d + bigdecimal.BigDecimal("1")) # 4.14159265358979323846 +``` + +Once wrapped by the `python/decimo/` package with the alias: + +```python +from decimo import Decimal +d = Decimal("1") / Decimal("3") # prints 0.333333... +assert isinstance(d, Decimal) # True +``` + +The Mojo side will require adding `py_init`, `py_add`, `py_str` etc. as `@staticmethod` methods on `BigDecimal` (or as free functions), following the binding pattern described in Section 2.3. diff --git a/pixi.toml b/pixi.toml index 9a6ba7f2..bf467903 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" From 319a327ed8edfa6b723b3a333017b1831e62948f Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 17:56:18 +0100 Subject: [PATCH 2/6] Update the folder name and structure --- .gitignore | 6 +- docs/plans/mojo4py.md | 253 +++++++++++++----------------------- pixi.toml | 4 + python/decimo.py | 122 +++++++++++++++++ python/decimo_module.mojo | 155 ++++++++++++++++++++++ python/tests/test_decimo.py | 65 +++++++++ 6 files changed, 438 insertions(+), 167 deletions(-) create mode 100644 python/decimo.py create mode 100644 python/decimo_module.mojo create mode 100644 python/tests/test_decimo.py 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/docs/plans/mojo4py.md b/docs/plans/mojo4py.md index f4c1be51..87dfe8ae 100644 --- a/docs/plans/mojo4py.md +++ b/docs/plans/mojo4py.md @@ -1,5 +1,7 @@ # 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. @@ -110,136 +112,69 @@ These are hard constraints today, expected to improve over time: ## 4. File Structure -The binding code lives under a new `src/decimo/python/` sub-package, separate from the core implementation. This keeps concerns clean and the core library free of Python-specific boilerplate. - -```txt -src/ -└── decimo/ - ├── __init__.mojo (existing) - ├── decimal128/ (existing) - ├── bigdecimal/ (existing) - ├── bigint/ (existing) - ├── biguint/ (existing) - ├── errors.mojo (existing) - ├── prelude.mojo (existing) - ├── rounding_mode.mojo (existing) - └── python/ ← NEW sub-package - ├── __init__.mojo ← top-level PyInit_decimo() entry point - ├── bind_decimal128.mojo - ├── bind_bigdecimal.mojo - ├── bind_bigint.mojo - ├── bind_biguint.mojo - └── helpers.mojo ← shared conversion helpers (PythonObject ↔ RoundingMode, etc.) -``` - -The `python/__init__.mojo` contains the single `@export fn PyInit_decimo()` that calls each `bind_*` file's registration function, then calls `m.finalize()`. - -Alternatively, for initial simplicity, expose each type as its own module: +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 -src/decimo/python/ - ├── decimal128_module.mojo → builds to decimal128.so - ├── bigdecimal_module.mojo → builds to bigdecimal.so - ├── bigint_module.mojo → builds to bigint.so - └── biguint_module.mojo → builds to biguint.so +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 ``` -**Recommendation:** Start with separate modules per type (easier to iterate, easier to test in isolation), then merge into a single `decimo` module once the API stabilizes. +The core Mojo library (`src/decimo/`) is not modified — all binding logic lives in `python/decimo_module.mojo` as free functions. -### 4.1 Python-side Wrapper Package (`python/decimo/`) +### 4.1 Two-Layer Architecture -A thin Python wrapper package provides: +Due to CPython slot limitations (see Phase 0 findings in Section 8), a two-layer pattern is used: -- Type stubs (`.pyi` files) for IDE autocomplete and mypy/pyright support -- Pythonic re-exports and documentation -- The canonical `Decimal` alias (see below) -- Optional pure-Python fallback for platforms where the `.so` is unavailable +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. -```txt -python/ -└── decimo/ ← Python package (installable via pip/conda) - ├── __init__.py ← imports the .so, re-exports, and sets Decimal alias - ├── bigdecimal.pyi ← type stubs - ├── decimal128.pyi - ├── bigint.pyi - ├── biguint.pyi - └── py.typed ← PEP 561 marker -``` +This keeps the core `BigDecimal` struct unmodified and provides full Pythonic behavior (operators, `str()`, `repr()`, comparisons). ### 4.2 The `Decimal` Alias -Set the alias in the Python wrapper `__init__.py`, not in the Mojo binding layer: +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/__init__.py -from ._decimo import BigDecimal, Decimal128, BigInt, BigUint # .so symbols - -# Expose Decimal as a friendly alias for BigDecimal. -# Because it's assignment, not subclassing, both names refer to the -# *exact same type object*: isinstance(d, Decimal) == isinstance(d, BigDecimal). -Decimal = BigDecimal - -__all__ = ["Decimal", "BigDecimal", "Decimal128", "BigInt", "BigUint"] +# 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 can then use either name interchangeably: +Python users import like: ```python -from decimo import Decimal # preferred, familiar name -from decimo import BigDecimal # also works, same object - -d = Decimal("1.23456789") -print(isinstance(d, BigDecimal)) # True — same type -``` - -**Stub file:** The `.pyi` stub should document both names: - -```python -# python/decimo/bigdecimal.pyi -class BigDecimal: - def __init__(self, value: int | float | str) -> None: ... - def __add__(self, other: BigDecimal) -> BigDecimal: ... - # ... - -Decimal = BigDecimal # alias +from decimo import Decimal # preferred +from decimo import BigDecimal # also works, same class ``` --- ## 5. Build System Integration (pixi.toml) -New tasks to add to `pixi.toml`: +Tasks added to `pixi.toml`: ```toml -# Build Python extension modules (.so files) -py_build_bigdecimal = """ - pixi run mojo build src/decimo/python/bigdecimal_module.mojo \ - --emit shared-lib \ - -I src \ - -o python/decimo/_decimo_bigdecimal.so -""" -py_build_decimal128 = """ - pixi run mojo build src/decimo/python/decimal128_module.mojo \ - --emit shared-lib \ - -I src \ - -o python/decimo/_decimo_decimal128.so -""" -py_build_bigint = """ - pixi run mojo build src/decimo/python/bigint_module.mojo \ - --emit shared-lib \ - -I src \ - -o python/decimo/_decimo_bigint.so -""" -py_build = "pixi run py_build_bigdecimal && pixi run py_build_decimal128 && pixi run py_build_bigint" - -# Run Python tests -py_test = "pixi run py_build && python -m pytest tests/python/ -v" - -# Build + install locally for interactive testing -py_install = "pixi run py_build && pip install -e python/ --no-build-isolation" +# 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" ``` -Key point: the `-I src` flag ensures `import decimo` in the binding Mojo file resolves to `src/decimo/`. I do **not** need to pre-package `decimo.mojopkg` for the binding build — the source directory import works directly with `mojo build`. +- `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`. --- @@ -248,17 +183,13 @@ Key point: the `-I src` flag ensures `import decimo` in the binding Mojo file re ### 6.1 Test Layout ```txt -tests/ -├── python/ ← NEW -│ ├── test_bigdecimal.py ← primary -│ ├── test_decimal128.py -│ ├── test_bigint.py -│ ├── test_biguint.py -│ ├── test_aliases.py ← verifies Decimal is BigDecimal -│ └── conftest.py ← shared fixtures, e.g. pre-built .so path -└── test_all.sh (existing, Mojo-native tests) +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):** @@ -313,12 +244,10 @@ d = Decimal("1.23e5") # from Python str ### 6.3 CI Integration -Add to the CI pipeline (if one exists) or to `test_all.sh`: +Add to CI pipeline: ```bash -# In tests/test_all.sh or a new tests/test_python.sh -pixi run py_build -python -m pytest tests/python/ -v --tb=short +pixi run pytest ``` --- @@ -400,12 +329,38 @@ jobs: ## 8. Roadmap -### Phase 0 — Proof of Concept +### 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. -- [ ] Write binding for a single function: `BigDecimal.__init__(str)` and `BigDecimal.__str__`. -- [ ] Manually build the `.so` with `mojo build --emit shared-lib -I src`. -- [ ] Import from Python, confirm round-trip: `str(BigDecimal("1.23")) == "1.23"`. -- [ ] Identify trait gaps (`Representable`, `Movable`, etc., should be fine). +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 @@ -470,57 +425,23 @@ jobs: --- -## 10. Quick-Start Skeleton +## 10. Quick-Start -To start the proof of concept, create `src/decimo/python/bigdecimal_module.mojo`: - -```mojo -from python import PythonObject -from python.bindings import PythonModuleBuilder -from os import abort -from decimo.bigdecimal import BigDecimal - -@export -fn PyInit_bigdecimal() -> PythonObject: - try: - var m = PythonModuleBuilder("bigdecimal") - _ = m.add_type[BigDecimal]("BigDecimal") - .def_py_init[BigDecimal.py_init]() - .def_method[BigDecimal.py_add]("__add__") - .def_method[BigDecimal.py_sub]("__sub__") - .def_method[BigDecimal.py_mul]("__mul__") - .def_method[BigDecimal.py_truediv]("__truediv__") - .def_method[BigDecimal.py_str]("__str__") - .def_method[BigDecimal.py_repr]("__repr__") - return m.finalize() - except e: - abort(String("error creating bigdecimal Python module: ", e)) -``` - -Then build it: +Build and test with two commands: ```bash -mojo build src/decimo/python/bigdecimal_module.mojo \ - --emit shared-lib \ - -I src \ - -o bigdecimal.so +pixi run pybuild # Compiles python/decimo_module.mojo → python/_decimo.so +pixi run pytest # Builds, then runs python/tests/test_decimo.py ``` -Then in Python: - -```python -import bigdecimal -d = bigdecimal.BigDecimal("3.14159265358979323846") -print(d) # 3.14159265358979323846 -print(d + bigdecimal.BigDecimal("1")) # 4.14159265358979323846 -``` - -Once wrapped by the `python/decimo/` package with the alias: +From Python: ```python from decimo import Decimal -d = Decimal("1") / Decimal("3") # prints 0.333333... -assert isinstance(d, Decimal) # True -``` -The Mojo side will require adding `py_init`, `py_add`, `py_str` etc. as `@staticmethod` methods on `BigDecimal` (or as free functions), following the binding pattern described in Section 2.3. +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.toml b/pixi.toml index bf467903..cb34a948 100644 --- a/pixi.toml +++ b/pixi.toml @@ -51,3 +51,7 @@ doc = "clear && pixi run mojo doc -o docs/doc.json --diagnose-missing-doc-string 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" + +# 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" diff --git a/python/decimo.py b/python/decimo.py new file mode 100644 index 00000000..069aa89b --- /dev/null +++ b/python/decimo.py @@ -0,0 +1,122 @@ +"""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 __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): + other = Decimal(other) + return self._inner.lt(other._inner) + + def __le__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + return self._inner.le(other._inner) + + def __gt__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + return not self._inner.le(other._inner) + + def __ge__(self, other): + if not isinstance(other, Decimal): + other = Decimal(other) + return not self._inner.lt(other._inner) + + def __ne__(self, other): + return not self.__eq__(other) + + def __bool__(self): + return str(self) != "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..a9ed1747 --- /dev/null +++ b/python/decimo_module.mojo @@ -0,0 +1,155 @@ +# ===----------------------------------------------------------------------=== # +# 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, Python +from python.bindings import PythonModuleBuilder +from os import abort +from memory import UnsafePointer + +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_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_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..5ec24a14 --- /dev/null +++ b/python/tests/test_decimo.py @@ -0,0 +1,65 @@ +"""Verify BigDecimal round-trip through Mojo-Python bindings.""" + +import sys +from pathlib import Path + +# Add python/ directory to sys.path so `import decimo` resolves to decimo.py +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from decimo import Decimal, BigDecimal + +print("=== decimo mojo4py Phase 0 ===") +print() + +# --- Alias test --- +assert Decimal is BigDecimal, "Decimal should be BigDecimal" +print("[PASS] Decimal is BigDecimal") + +# --- Construction from string --- +d = Decimal("3.14159265358979323846") +print(f"[PASS] str = {d}") +print(f"[PASS] repr = {repr(d)}") +print() + +# --- Round-trip --- +s = "123456789.987654321" +d2 = Decimal(s) +assert str(d2) == s, f"Round-trip failed: expected {s!r}, got {str(d2)!r}" +print(f"[PASS] Round-trip: str(Decimal({s!r})) == {s!r}") + +# --- Arithmetic --- +a = Decimal("1.5") +b = Decimal("2.3") +print(f"[PASS] {a} + {b} = {a + b}") +print(f"[PASS] {a} - {b} = {a - b}") +print(f"[PASS] {a} * {b} = {a * b}") +print(f"[PASS] -{a} = {-a}") +print(f"[PASS] abs({-a}) = {abs(-a)}") +print() + +# --- Comparison --- +assert Decimal("1.5") == Decimal("1.5"), "equality failed" +assert Decimal("1.5") < Decimal("2.3"), "less-than failed" +assert Decimal("2.3") > Decimal("1.5"), "greater-than failed" +assert Decimal("1.5") <= Decimal("1.5"), "less-equal failed" +assert Decimal("1.5") >= Decimal("1.5"), "greater-equal failed" +assert Decimal("1.5") != Decimal("2.3"), "not-equal failed" +print("[PASS] All comparisons") + +# --- Integer input --- +d3 = Decimal("42") +assert str(d3) == "42", f"Int input failed: got {str(d3)!r}" +print(f"[PASS] Decimal('42') = {d3}") + +# --- Large number --- +big = Decimal("99999999999999999999999999999999999999.123456789") +print(f"[PASS] Large number: {big}") +print() + +# --- Mixed arithmetic with auto-convert --- +result = Decimal("10") + Decimal("5") +assert str(result) == "15", f"Mixed arith failed: got {str(result)!r}" +print(f"[PASS] Decimal('10') + Decimal('5') = {result}") +print() + +print("=== All Phase 0 tests passed! ===") From 6e227696b5de1d8c3a9a43eb8279399b0fd43263 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 20:09:42 +0100 Subject: [PATCH 3/6] Update tests --- .github/workflows/run_tests.yaml | 238 +++++++++++++++++++---- .pre-commit-config.yaml | 9 +- pixi.lock | 29 +++ pixi.toml | 76 +++++--- python/_decimo.pyi | 18 ++ python/decimo.py | 7 + python/decimo_module.mojo | 11 ++ python/tests/test_decimo.py | 149 ++++++++++---- tests/cli/test_evaluator.mojo | 18 +- tests/test_all.sh | 15 +- tests/test_big.sh | 8 - tests/test_bigdecimal.sh | 6 + tests/test_bigint.sh | 6 + tests/test_bigint10.sh | 6 + tests/test_biguint.sh | 6 + tests/test_binary.sh | 8 - tests/test_decimal128.sh | 6 + tests/test_decimo.sh | 8 + tests/{test_toml.sh => test_tomlmojo.sh} | 2 +- 19 files changed, 479 insertions(+), 147 deletions(-) create mode 100644 python/_decimo.pyi delete mode 100644 tests/test_big.sh create mode 100755 tests/test_bigdecimal.sh create mode 100755 tests/test_bigint.sh create mode 100755 tests/test_bigint10.sh create mode 100755 tests/test_biguint.sh delete mode 100644 tests/test_binary.sh create mode 100755 tests/test_decimal128.sh create mode 100644 tests/test_decimo.sh rename tests/{test_toml.sh => test_tomlmojo.sh} (67%) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index 22ba7263..b4e89f79 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 build - 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 pytest - - 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/.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/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 cb34a948..2eb06663 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,49 +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""" # 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" +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" +doc = """clear && pixi run mojo doc -o docs/doc.json \ +--diagnose-missing-doc-strings --validate-doc-strings src/decimo""" # 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 clibuild" +buildcli = "pixi run mojo build -I src -I src/cli -o decimo src/cli/main.mojo" +tcli = "clear && pixi run clitest" +testcli = "bash tests/test_cli.sh" # 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" +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 pybuild && 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 index 069aa89b..0ddc5360 100644 --- a/python/decimo.py +++ b/python/decimo.py @@ -68,6 +68,13 @@ def __mul__(self, other): 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() diff --git a/python/decimo_module.mojo b/python/decimo_module.mojo index a9ed1747..fb588127 100644 --- a/python/decimo_module.mojo +++ b/python/decimo_module.mojo @@ -34,6 +34,7 @@ fn PyInit__decimo() -> PythonObject: .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") @@ -114,6 +115,16 @@ fn bigdecimal_mul( 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]() diff --git a/python/tests/test_decimo.py b/python/tests/test_decimo.py index 5ec24a14..826b6726 100644 --- a/python/tests/test_decimo.py +++ b/python/tests/test_decimo.py @@ -1,65 +1,128 @@ -"""Verify BigDecimal round-trip through Mojo-Python bindings.""" +"""Verify BigDecimal round-trip through Mojo-Python bindings. -import sys +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)) -from decimo import Decimal, BigDecimal +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 Decimal is BigDecimal, "Decimal should be BigDecimal" +assert decimo.Decimal is decimo.BigDecimal, "Decimal should be BigDecimal" print("[PASS] Decimal is BigDecimal") -# --- Construction from string --- -d = Decimal("3.14159265358979323846") -print(f"[PASS] str = {d}") +# --- 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() -# --- Round-trip --- -s = "123456789.987654321" -d2 = Decimal(s) -assert str(d2) == s, f"Round-trip failed: expected {s!r}, got {str(d2)!r}" -print(f"[PASS] Round-trip: str(Decimal({s!r})) == {s!r}") - -# --- Arithmetic --- -a = Decimal("1.5") -b = Decimal("2.3") -print(f"[PASS] {a} + {b} = {a + b}") -print(f"[PASS] {a} - {b} = {a - b}") -print(f"[PASS] {a} * {b} = {a * b}") -print(f"[PASS] -{a} = {-a}") -print(f"[PASS] abs({-a}) = {abs(-a)}") -print() +# --- Arithmetic (cross-validated) --- +pairs = [ + ("1.5", "2.3"), + ("100", "0.001"), + ("0", "999"), + ("-3.5", "2.5"), + ("1", "3"), +] + -# --- Comparison --- -assert Decimal("1.5") == Decimal("1.5"), "equality failed" -assert Decimal("1.5") < Decimal("2.3"), "less-than failed" -assert Decimal("2.3") > Decimal("1.5"), "greater-than failed" -assert Decimal("1.5") <= Decimal("1.5"), "less-equal failed" -assert Decimal("1.5") >= Decimal("1.5"), "greater-equal failed" -assert Decimal("1.5") != Decimal("2.3"), "not-equal failed" -print("[PASS] All comparisons") - -# --- Integer input --- -d3 = Decimal("42") -assert str(d3) == "42", f"Int input failed: got {str(d3)!r}" -print(f"[PASS] Decimal('42') = {d3}") - -# --- Large number --- -big = Decimal("99999999999999999999999999999999999999.123456789") -print(f"[PASS] Large number: {big}") +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() -# --- Mixed arithmetic with auto-convert --- -result = Decimal("10") + Decimal("5") -assert str(result) == "15", f"Mixed arith failed: got {str(result)!r}" -print(f"[PASS] Decimal('10') + Decimal('5') = {result}") +# --- 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" From 3ec95aca6b84a6c990c2bd181a9ad1281ffbee04 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 20:14:16 +0100 Subject: [PATCH 4/6] Fix bugs --- .github/workflows/run_tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index b4e89f79..ac9ab59b 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -177,7 +177,7 @@ jobs: - name: pixi install run: pixi install - name: Build CLI binary - run: pixi run build + run: pixi run buildcli - name: Run tests run: bash ./tests/test_cli.sh @@ -199,7 +199,7 @@ jobs: - name: pixi install run: pixi install - name: Build & run Python tests - run: pixi run pytest + run: pixi run testpy # ── Format check ───────────────────────────────────────────────────────────── format-check: From 72b2a07206b8bf33503ac00715fcd6f44906f0d9 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 20:17:41 +0100 Subject: [PATCH 5/6] Fix bug --- pixi.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pixi.toml b/pixi.toml index 2eb06663..6d59eff7 100644 --- a/pixi.toml +++ b/pixi.toml @@ -23,6 +23,10 @@ format = """pixi run mojo format ./src \ &&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 \ @@ -69,12 +73,8 @@ 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""" - # cli calculator -bcli = "clear && pixi run clibuild" +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 clitest" testcli = "bash tests/test_cli.sh" @@ -83,5 +83,5 @@ testcli = "bash tests/test_cli.sh" 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 pybuild && pixi run python python/tests/test_decimo.py" +testpy = "pixi run buildpy && pixi run python python/tests/test_decimo.py" tpy = "clear && pixi run testpy" From 1104fc1d216a894b7818b82879f9759948c40065 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 2 Mar 2026 20:30:12 +0100 Subject: [PATCH 6/6] Fix bugs --- pixi.toml | 2 +- python/decimo.py | 27 +++++++++++++++++++++------ python/decimo_module.mojo | 3 +-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pixi.toml b/pixi.toml index 6d59eff7..4284102c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -76,7 +76,7 @@ dec_debug = """clear && pixi run package && cd benches/decimal128 \ # cli calculator 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 clitest" +tcli = "clear && pixi run testcli" testcli = "bash tests/test_cli.sh" # python bindings (mojo4py) diff --git a/python/decimo.py b/python/decimo.py index 0ddc5360..8ec75594 100644 --- a/python/decimo.py +++ b/python/decimo.py @@ -100,29 +100,44 @@ def __eq__(self, other): def __lt__(self, other): if not isinstance(other, Decimal): - other = Decimal(other) + try: + other = Decimal(other) + except Exception: + return NotImplemented return self._inner.lt(other._inner) def __le__(self, other): if not isinstance(other, Decimal): - other = Decimal(other) + try: + other = Decimal(other) + except Exception: + return NotImplemented return self._inner.le(other._inner) def __gt__(self, other): if not isinstance(other, Decimal): - other = Decimal(other) + try: + other = Decimal(other) + except Exception: + return NotImplemented return not self._inner.le(other._inner) def __ge__(self, other): if not isinstance(other, Decimal): - other = Decimal(other) + try: + other = Decimal(other) + except Exception: + return NotImplemented return not self._inner.lt(other._inner) def __ne__(self, other): - return not self.__eq__(other) + eq_result = self.__eq__(other) + if eq_result is NotImplemented: + return NotImplemented + return not eq_result def __bool__(self): - return str(self) != "0" + return str(self) not in ("0", "-0") # Also expose as BigDecimal for users who prefer the full name diff --git a/python/decimo_module.mojo b/python/decimo_module.mojo index fb588127..6d72b24c 100644 --- a/python/decimo_module.mojo +++ b/python/decimo_module.mojo @@ -9,10 +9,9 @@ # https://docs.modular.com/mojo/manual/python/mojo-from-python # ===----------------------------------------------------------------------=== # -from python import PythonObject, Python +from python import PythonObject from python.bindings import PythonModuleBuilder from os import abort -from memory import UnsafePointer from decimo import BigDecimal