diff --git a/README.md b/README.md index ba27757..7a9c938 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,91 @@ # pyropust -A proof-of-concept library that brings Rust's `Result` and `Option` types to Python, treating failures as values instead of exceptions. +**Rust-powered, type-safe pipelines for Python.** -The concept: "A robust rope for your Python orchestration. Drop a Ropust into the dangerous freedom of Python." +pyropust bridges the messy, exception-heavy reality of Python with the explicit, composable world of Rust’s `Result / Option`. -## Who This Is For +This is **not just another Result library**. -Pyropust is for Python developers who want explicit, type-safe control over error flow and data transformations, especially when bridging Python and Rust or when pipelines become hard to reason about with exceptions alone. +pyropust is built around three core ideas: -Common problems it helps with: +- **Blueprints** — typed, declarative data-processing pipelines +- **Rust operators** — hot-path operations (e.g. JSON decoding) executed safely and efficiently in Rust +- **Exception boundaries** — explicit normalization of Python exceptions into `Result` -- Making error propagation explicit and type-checked (Result/Option). -- Defining typed, composable pipelines with predictable failure modes (Blueprint). -- Keeping Python↔Rust boundaries safe without scattering try/except logic. +If you have ever thought: -## Why Not Exceptions? +> “I want Rust-like error flow, but I live in Python and can’t avoid exceptions” -1. **Explicit control flow**: Treat failures as values, not control flow jumps. -2. **Error Locality**: Handle errors exactly where they happen, making the code easier to trace. -3. **No implicit None**: Force explicit `unwrap()` or `is_some()` checks. -4. **Railway Oriented**: Naturally build linear pipelines where data flows or short-circuits safely.using generators +pyropust is designed for you. -## Quick Start +--- -Start small and adopt features gradually: +## Why pyropust exists -1. Wrap exceptions with `@catch` to get `Result`. -2. Use `Result`/`Option` explicitly in Python code. -3. Use `@do` for short-circuiting chains. -4. Introduce `Blueprint` for typed pipelines. +Python already has multiple `Result / Option` libraries. The problem is not representation — it is integration. -## Direct Usage +In real Python systems: -You can use `Result` and `Option` types directly for manual handling or functional chaining, just like in Rust. +- Most libraries raise exceptions (`requests`, `boto3`, `sqlalchemy`, ...) +- Data transformation is written as long chains of `try/except` +- Type checkers lose track of what can fail and where -### Manual Handling +pyropust treats exceptions as an external reality and provides a structured boundary where they are captured, typed, and composed. + +--- + +## Why not exceptions? + +Exceptions are great for failures that should abort the current operation. They are less suitable for orchestration and pipelines: + +- They hide control flow in call stacks +- They complicate typed composition across steps +- They are hard to make explicit at module boundaries + +pyropust makes failures **values** so they can be composed, transformed, and tested like data. + +--- + +## Adoption path + +You do not need to switch everything at once. A realistic path is: + +1. Wrap exceptions with `@catch` +2. Use `Result / Option` explicitly in Python code +3. Use `@do` for structured propagation +4. Introduce `Blueprint` for typed pipelines + +--- + +## Key concepts + +### 1) Result and Option + +Rust-style `Result[T, E]` and `Option[T]` as first-class values. + +```python +from pyropust import Ok, Err, Some, None_ + +value = Ok(10) +error = Err("boom") + +maybe = Some(42) +empty = None_() +``` + +Keep Option short and explicit: you must unwrap or provide defaults. ```python -from pyropust import Ok, Err, Result - -def divide(a: int, b: int) -> Result[float, str]: - if b == 0: - return Err("Division by zero") - return Ok(a / b) - -res = divide(10, 2) -if res.is_ok(): - print(f"Success: {res.unwrap()}") # Success: 5.0 -else: - print(f"Failure: {res.unwrap_err()}") +from pyropust import Some, None_ + +user = Some("alice") +name = user.unwrap_or("guest") + +missing = None_() +name2 = missing.unwrap_or("guest") ``` -### Functional Chaining (`map`, `and_then`) +#### Functional Chaining (`map`, `and_then`) Avoid `if` checks by chaining operations. @@ -83,131 +117,115 @@ print(res.unwrap()) # "Value is 246" > result = fetch_data().and_then(validate) > ``` -### Option Type (Safe None Handling) +--- -No more `AttributeError: 'NoneType' object has no attribute '...'`. +### 2) Blueprint: typed pipelines -```python -from pyropust import Some, None_, Option +A **Blueprint** is a declarative pipeline that describes what happens to data, not how it is wired together. -def find_user(user_id: int) -> Option[str]: - return Some("Alice") if user_id == 1 else None_() +```python +from pyropust import Blueprint, Op -name_opt = find_user(1) -# You MUST check or unwrap explicitly -name = name_opt.unwrap_or("Guest") -print(f"Hello, {name}!") # Hello, Alice! +bp = ( + Blueprint.for_type(str) + .pipe(Op.json_decode()) + .pipe(Op.get("user")) + .pipe(Op.get("id")) +) ``` -## Blueprint (Typed Pipelines) +Characteristics: -Use `Blueprint` to define a typed, composable pipeline with explicit error handling. The primary value is clarity and type-safety across a sequence of operations. Performance can improve in some cases, but it is not guaranteed and should be treated as a secondary benefit. +- **Typed**: `Blueprint.for_type(T)` gives static analyzers a concrete starting point +- **Composable**: pipelines are values, not control flow +- **No runtime type checks**: types are for humans and tools, not runtime checks -```python -from pyropust import Blueprint, Op, run +Blueprints are the primary abstraction of pyropust. -# Define a pipeline -bp = ( - Blueprint() - .pipe(Op.split(",")) - .pipe(Op.index(0)) - .pipe(Op.expect_str()) - .pipe(Op.to_uppercase()) -) +Blueprints are inert definitions. Use `run(bp, value)` to execute them, typically inside an exception boundary. -# Execute with type-safe error handling -result = run(bp, "hello,world") -if result.is_ok(): - print(result.unwrap()) # "HELLO" -else: - print(f"Error: {result.unwrap_err().message}") -``` +--- + +### 3) Rust operators (hot paths) -### Operators & Integration +Some operations are performance-critical and error-prone. pyropust implements these as Rust-backed operators: -Pyropust provides built-in operators to handle common data tasks safely in Rust, while allowing flexible escape hatches to Python. +- `Op.json_decode()` +- (future) `Op.base64_decode()`, `Op.url_parse()`, ... -#### Core Operators +Benefits: -- **`Op.json_decode()`**: Enables a **Turbo JSON Path** (high-performance Rust parsing) when used as the first operator in a Blueprint. -- **`Op.map_py(fn)`**: Runs a custom Python callback within the pipeline. It’s a "safety hatch"—if the callback raises an exception, it is caught and converted into a `RopustError` (code: `py_exception`) with the original traceback stored in `metadata["py_traceback"]`. -- **Built-in logic**: Includes `as_str()`, `as_int()`, `split()`, `index()`, `get()`, and `to_uppercase()`. See [docs/operations.md](docs/operations.md) for the full list. +- Faster execution for hot paths +- Consistent error semantics +- No Python-level exceptions leaking through the pipeline -#### Design Principles +You can always fall back to Python: -- **Type-safe construction**: `Blueprint.for_type(T)` is a helper for static type checkers (Pyright/Mypy). It provides zero-runtime-overhead type hinting. -- **Exception Bridge**: Use `exception_to_ropust_error()` to normalize external exceptions (like `requests.Error`) into a consistent `RopustError` format. +```python +bp = bp.pipe(Op.map_py(lambda x: x + 1)) +``` -> [!NOTE] -> **Why a shared error format?** -> By unifying errors into `RopustError`, you get a consistent interface across Python and Rust. You can reliably access fields like `path`, `expected`, and `got` without losing context (like Python tracebacks) during pipeline orchestration. See [docs/errors.md](docs/errors.md) for details. +Rust where it matters, Python where it’s convenient. -## Syntactic Sugar: `@do` Decorator +--- -Generator-based short-circuiting reproduces Rust's `?` operator in Python. +### 4) Exception boundaries (`@catch`) + +Python exceptions are unavoidable. pyropust makes them explicit. ```python -from pyropust import Ok, Result, do +from pyropust import Blueprint, Op, catch, run -@do -def process(value: str) -> Result[str, object]: - text = yield Ok(value) # Type checkers infer 'text' as str - upper = yield Ok(text.upper()) - return Ok(f"Processed: {upper}") +bp = ( + Blueprint.for_type(str) + .pipe(Op.json_decode()) + .pipe(Op.get("value")) +) -print(process("hello").unwrap()) # "Processed: HELLO" +@catch +def load_value(payload: str): + return run(bp, payload) ``` -## Border Control: Exception Interoperability +Inside the boundary: -Real-world Python code uses libraries like `requests`, `boto3`, and `sqlalchemy` that throw exceptions. pyropust provides tools to safely bridge between the "exception world" and the "Result world". +- Exceptions are captured +- Normalized into `Err` +- Enriched with traceback metadata (`py_traceback`) -### Converting Exceptions to Results: `@catch` +Outside the boundary: -Use `@catch` to wrap exception-throwing code and convert it into safe `Result` values. +- No hidden control flow +- Failures are values -```python -from pyropust import catch -import requests - -# Wrap existing libraries that throw exceptions -@catch(requests.RequestException) -def fetch_data(url: str) -> dict: - response = requests.get(url) - response.raise_for_status() - return response.json() - -# Now returns Result[dict, RopustError] instead of raising -result = fetch_data("https://api.example.com/data") -if result.is_ok(): - data = result.unwrap() -else: - print(f"Failed to fetch: {result.unwrap_err().message}") -``` +This makes error flow visible, testable, and composable. -**When to use:** +--- -- Wrapping third-party libraries that throw exceptions -- Creating safe boundaries around risky I/O operations -- Gradually introducing pyropust into existing codebases +### 5) `@do`: Rust-like `?` for Python -You can also use `@catch` without arguments to catch all exceptions, or use `Result.attempt()` directly: +The `@do` decorator enables linear, Rust-style propagation of `Result`. ```python -from pyropust import Result +from pyropust import Ok, Result, do -# Inline exception handling -result = Result.attempt(lambda: int("not-a-number"), ValueError) -# Returns Err(RopustError) instead of raising ValueError +@do +def process(data: str) -> Result[str, object]: + text = yield Ok(data) + return Ok(text.upper()) ``` -### Converting Results to Exceptions: `unwrap_or_raise` +This is not syntax sugar over exceptions — it is structured propagation of `Result` values. + +--- -At the edges of your application (e.g., web framework endpoints), you may need to convert `Result` back into exceptions. +## Framework boundaries + +You can safely use pyropust in frameworks that expect exceptions by converting `Result` back into exceptions at the boundary. ```python -from pyropust import Result, do, catch from fastapi import FastAPI, HTTPException +from pyropust import Result, catch app = FastAPI() @@ -230,61 +248,88 @@ def create_user(data: dict): return {"user": parsed} ``` -**When to use:** +--- -- Framework endpoints (FastAPI, Flask, Django) -- CLI tools that need to exit with error codes -- Any boundary where exceptions are the expected error handling mechanism +## Installation -## Type Checker Support +> pyropust is currently experimental. -- **Pyright**: Primary focus - verifies that `yield` correctly infers types -- **MyPy**: Strict mode compatibility verified +```bash +pip install pyropust +``` -## Installation +Supported: -### Requirements +- Python 3.10+ +- CPython (wheels provided) -- Python 3.12+ -- Rust toolchain (pinned via `rust-toolchain.toml`) -- [uv](https://github.com/astral-sh/uv) -- [cargo-make](https://github.com/sagiegurari/cargo-make) +Note: Some platforms may require a Rust toolchain to build from source. -### Setup +--- -```bash -# Install dependencies and build extension -uv sync -makers dev +## Minimal example (30 seconds) -# Or run everything at once -makers ci +```python +from pyropust import Blueprint, Op, catch, run + +bp = ( + Blueprint.for_type(str) + .pipe(Op.json_decode()) + .pipe(Op.get("value")) +) + +@catch +def run_value(payload: str): + return run(bp, payload) + +result = run_value('{"value": 123}') ``` -## Development +- No `try/except` +- Failures are explicit +- The pipeline is reusable and testable -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code generation system, and testing guidelines. +--- -## Benchmarks +## Documentation -Performance is workload-dependent. The primary value of `Blueprint` is type-safety and composability, not guaranteed speedups. +- [Operators](docs/operations.md) +- [Errors](docs/errors.md) +- [Benchmarks](docs/benchmarks.md) -Run the included benchmark: +--- -```bash -uv run python bench/bench_blueprint_vs_python.py -``` +## Non-goals + +pyropust intentionally does not aim to: + +- Replace Python exceptions everywhere +- Be a general-purpose FP toolkit +- Hide Python’s dynamic nature + +It is a boundary and pipeline tool, not a new language. + +--- + +## Roadmap + +- More Rust-backed operators +- Benchmark suite and published numbers +- Better IDE / type-checker ergonomics +- Stabilization of public APIs + +--- -Key findings: +## Stability -- Performance varies by workload; some cases are slower, some are comparable, and some benefit from fewer boundary crossings. -- Larger pipelines and repeated runs can show improvements, but tiny operators can be slower. -- Treat benchmarks as measurements, not a promise of speedups. +- APIs may change before 1.0 +- Semantic versioning will start at 1.0 +- Breaking changes will be documented -**For detailed results and methodology, see [docs/benchmarks.md](docs/benchmarks.md).** +--- ## License -MIT License - see [LICENSE](LICENSE) file for details. +MIT Copyright (c) 2025 K-dash diff --git a/tools/bump-version.sh b/tools/bump-version.sh new file mode 100755 index 0000000..76f5390 --- /dev/null +++ b/tools/bump-version.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./tools/bump-version.sh + +# Fetch latest tags from remote +echo "Fetching latest tags from remote..." +git fetch --tags --quiet + +# Get the latest tag version +LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -n 1) +if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found." + LATEST_VERSION="none" +else + LATEST_VERSION="${LATEST_TAG#v}" + echo "Current latest tag: ${LATEST_TAG} (${LATEST_VERSION})" +fi + +# Prompt for new version +echo "" +read -p "Enter new version: " NEW_VERSION + +if [ -z "$NEW_VERSION" ]; then + echo "Error: Version cannot be empty" + exit 1 +fi + +echo "Bumping version to ${NEW_VERSION}..." + +# Update Cargo.toml +echo "Updating Cargo.toml..." +sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" Cargo.toml + +# Update pyproject.toml +echo "Updating pyproject.toml..." +sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" pyproject.toml + +# Clean up backup files +find . -name "*.bak" -delete + +# Update Cargo.lock +echo "Updating Cargo.lock..." +cargo check --quiet + +# Sync Python dependencies +echo "Syncing Python dependencies..." +uv sync --quiet + +echo "✅ Version bumped to ${NEW_VERSION}" +echo "" +echo "Next steps:" +echo " 1. Review changes: git diff" +echo " 2. Commit: git add -A && git commit -m 'chore: bump version to ${NEW_VERSION}'" +echo " 3. Tag: git tag v${NEW_VERSION}" +echo " 4. Push: git push origin main && git push origin v${NEW_VERSION}"