diff --git a/.vscode/settings.json b/.vscode/settings.json index 9de623a..eada51a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "coro", "destructures", "elif", "eprintln", @@ -7,7 +8,9 @@ "Fantomas", "fastapi", "Feliz", + "getcwd", "Hashnode", + "pathlib", "pyname", "stroustrup" ] diff --git a/CLAUDE.md b/CLAUDE.md index ca5fd2f..d991bc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **F# Advent 2025 blog post project** demonstrating Fable.Python capabilities. -**Fabletext** is a literate programming converter (inspired by jupytext) written in F# that transpiles to Python via Fable.Python. It processes `.fs` files with embedded Markdown comments (FSharp.Formatting conventions) and outputs GitHub-flavored Markdown suitable for publishing on platforms like Hashnode. +**Fable.Literate** is a literate programming converter (inspired by jupytext) written in F# that transpiles to Python via Fable.Python. It processes `.fs` files with embedded Markdown comments (FSharp.Formatting conventions) and outputs GitHub-flavored Markdown suitable for publishing on platforms like Hashnode. **Key concept:** The project is self-documenting - the chapters and converter generate the blog post that documents how they work. @@ -19,7 +19,7 @@ just build # Build all chapters and tools to Python just generate # Generate individual markdown docs from chapters just blogpost # Generate concatenated blogpost.md for publishing just format # Format Python with ruff -just lint # Lint Python (ruff) and Markdown (markdownlint) +just lint # Lint Markdown (markdownlint) just watch # Watch mode for development just clean # Clean generated files just all # Full pipeline: restore, build, generate, format, lint @@ -27,49 +27,78 @@ just all # Full pipeline: restore, build, generate, format, lint ## Architecture -### Fabletext Parser State Machine +### Fable.Literate AST Pipeline -The converter uses a line-by-line state machine with three states: +The converter follows a compiler-like architecture with three phases: -- **InMarkdown**: Inside `(** ... *)` blocks - emit content as-is -- **InCode**: F# code outside comment blocks - wrap in fenced code blocks -- **Hidden**: After `(*** hide ***)` - skip until next markdown block +1. **Parse**: Convert source lines into a Block AST +2. **Transform**: Filter hidden blocks, resolve Python includes +3. **Print**: Render the AST as Markdown -### Input/Output Transformation +### Literate Directives -| Input Pattern | Output | -|--------------|--------| +| Directive | Purpose | +|-----------|---------| | `(** content *)` | Raw markdown content | -| `(*** hide ***)` | Nothing (enters hidden mode) | +| `(*** hide ***)` | Hide following code from output | +| `(*** include-python: symbol ***)` | Include generated Python for symbol | | Regular F# code | Wrapped in ```fsharp fenced blocks | +### Escaping F# in Headings + +Use `` F`#` `` (backticks around `#`) in markdown headings to prevent markdownlint from interpreting it as ATX closed style: + +```fsharp +(** +## F`#` Async Workflows +*) +``` + ### File Structure ```text chapters/ -├── 01-introduction.fs # What is Fable.Python, why use it -├── 02-getting-started.fs # Setup, first project, hello world -├── 03-bindings.fs # Python interop, type bindings -└── 04-compatibility.fs # F# features supported, limitations -tools/ -├── fabletext.fs # Fabletext converter source (F#) -└── fabletext.fsproj +├── introduction.fs # What is Fable.Python, why use it +├── python.fs # F# concepts for Python developers +├── getting-started.fs # Setup, first project, hello world +├── interop.fs # Using existing Python libraries +├── bindings.fs # Creating Python bindings +├── compatibility.fs # F# features supported, limitations +├── async-programming.fs # async vs task, Python asyncio mapping +├── fable-v5.fs # Fable v5 features, Rust core, PyPI +├── pydantic.fs # Pydantic models, DTOs, validation +└── units-of-measure.fs # Compile-time dimensional analysis +Fable.Literate/ +├── App.fs # Fable.Literate converter source (F#) +└── Fable.Literate.fsproj output/ -├── chapters/ # Generated Python from chapters -└── tools/ - └── fabletext.py # Generated converter (Python) +├── chapters/ # Generated Python from chapters +└── Fable.Literate/ + └── app.py # Generated converter (Python) docs/ -├── *.md # Individual chapter markdown -└── blogpost.md # Concatenated for Hashnode +├── *.md # Individual chapter markdown +└── blogpost.md # Concatenated for Hashnode ``` +## Chapter Writing Guidelines + +- Each chapter is a literate F# file with embedded markdown +- Use `(** ... *)` for markdown content +- Use `(*** hide ***)` to hide setup code (module declarations, imports) +- Use `(*** include-python: symbolName ***)` to show generated Python +- Tables are auto-formatted by markdownlint - don't fight it +- Keep code examples self-contained and buildable + ## Fable.Python Considerations -- Use `Fable.Core` attributes where needed +- Use `Fable.Core` attributes (`[]`, `[]`, etc.) +- Use `Fable.Python.Pydantic` for Pydantic interop - Stick to Fable-compatible F# subset -- File I/O via Python interop (`[]` with `open`, `read`, etc.) +- `task { }` compiles to native Python `async def` (Fable v5) +- `async { }` for multi-target code (Python, .NET, JS) ## Resources - [Fable.Python docs](https://fable.io/docs/getting-started/python.html) - [Fable.Python GitHub](https://github.com/fable-compiler/Fable.Python/) +- [Content Plan](CONTENT-PLAN.md) - Chapter structure and TODO items diff --git a/chapters/async-programming.fs b/chapters/async-programming.fs index b55a7b5..e25111e 100644 --- a/chapters/async-programming.fs +++ b/chapters/async-programming.fs @@ -126,24 +126,13 @@ The `task` computation expression in .NET creates *hot* tasks that start immedia However, when compiled to Python via Fable, tasks become Python coroutines - which are *cold* just like Python's native `async def` functions. -In Fable v5, tasks compile to Python's native `async def` syntax, enabling seamless -integration with frameworks like FastAPI. +A key improvement in Fable v5 is that `task { }` now compiles to Python's native +`async def` syntax. Previously, Fable generated regular functions returning `Awaitable[T]`, +which frameworks like FastAPI couldn't recognize as async endpoints. *) open System.Threading.Tasks -let fetchDataTask () = - task { - do! Task.Delay 1000 - return "data from task" - } - -(** -### Fable v5: Native Python Async - -A key improvement in Fable v5 is that `task { }` now compiles to Python's `async def`: -*) - let processItemTask (item: string) = task { do! Task.Delay 100 @@ -152,15 +141,11 @@ let processItemTask (item: string) = (** This generates: +*) +(*** include-python: processItemTask ***) -```python -async def process_item_task(item: str) -> str: - await asyncio.sleep(0.1) - return item.upper() -``` - -Previously, Fable generated regular functions returning `Awaitable[T]`, which frameworks -like FastAPI couldn't recognize as async endpoints. Now the integration is seamless. +(** +Now frameworks like FastAPI can detect and handle these as proper async endpoints. ### Task vs Async: Key Differences @@ -188,6 +173,12 @@ like FastAPI couldn't recognize as async endpoints. Now the integration is seaml ### Working with Tasks *) +let fetchDataTask () = + task { + do! Task.Delay 100 // Do some async work + return "data from task" + } + let taskExample () = task { let! result = fetchDataTask () @@ -243,6 +234,30 @@ In Python, this generates: (*** include-python: simpleTask ***) +(** +### Running Tasks from F`#` + +To run a task and get its result in F#: +*) + +let runTaskExample () = + let tsk = simpleTask () + + // Block and wait for result + let result = tsk.GetAwaiter().GetResult() + printfn $"Got: {result}" + +(** +You can also await tasks inside other tasks: +*) + +let chainedTasks () = + task { + let! first = simpleTask () + let! second = simpleTask () + return first + second + } + (** ### Running in Python's Event Loop @@ -251,7 +266,6 @@ When your compiled Python code runs, you'll need an event loop. For scripts: ```python import asyncio -# If using task-based code async def main(): result = await simple_task() print(result) diff --git a/chapters/fable-v5.fs b/chapters/fable-v5.fs index e562c60..bbee3fb 100644 --- a/chapters/fable-v5.fs +++ b/chapters/fable-v5.fs @@ -45,6 +45,11 @@ module FableV5 - **Fixed-size arrays** - No more Python list quirks for byte streams - **Reliable numerics** - Fable 4's pure Python numerics were a constant source of bugs +While Rust is fast, don't expect dramatic speedups for typical F# code. Many F# +functions are higher-order and callback to Python - `List.map`, `List.filter`, +`Seq.fold`, etc. all invoke your Python lambdas. The Rust core handles the +data structures correctly; your code still runs at Python speed. + ## fable-library via PyPI Before Fable v5, the runtime was bundled in the NuGet package and copied @@ -58,6 +63,19 @@ pip install fable-library uv add fable-library ``` +For projects, pin your dependencies in `pyproject.toml`. For stable releases use +a minimum version constraint: + +```toml +dependencies = ["fable-library>=5.0.0"] +``` + +For alpha/beta releases, pin the exact version to avoid surprises: + +```toml +dependencies = ["fable-library==5.0.0a21"] +``` + This makes dependency management much simpler and follows Python conventions. ## Test Coverage @@ -78,10 +96,10 @@ To use Fable v5, install the alpha CLI: ```bash # Install Fable 5 CLI -dotnet tool install fable --version 5.0.0-alpha.17 +dotnet tool install fable --version 5.0.0-alpha.21 # Add Fable.Core to your project -dotnet add package Fable.Core --version 5.0.0-beta.2 +dotnet add package Fable.Core --version 5.0.0-beta.4 # Install the Python runtime uv add fable-library==5.0.0a17 diff --git a/chapters/getting-started.fs b/chapters/getting-started.fs index 054b9e3..04952c9 100644 --- a/chapters/getting-started.fs +++ b/chapters/getting-started.fs @@ -10,7 +10,8 @@ running as Python. You'll need: -- [.NET SDK](https://dotnet.microsoft.com/download) (6.0 or later) +- [.NET SDK](https://dotnet.microsoft.com/download) (6.0 or later. We recommend + installing the latest LTS version, currently .NET 10 - [Python 3.12+](https://www.python.org/downloads/) (Fable targets Python 3.12 or higher) ## Project Setup diff --git a/chapters/introduction.fs b/chapters/introduction.fs index 8f68b54..4258b57 100644 --- a/chapters/introduction.fs +++ b/chapters/introduction.fs @@ -23,6 +23,10 @@ F# is a functional-first language with powerful features like: With Fable.Python, you get all these benefits while targeting the Python ecosystem. +Python is the [most popular programming language](https://www.tiobe.com/tiobe-index/) +in the world. And no matter what you think of Python, it will always be the second +best language for everything. That ubiquity is exactly why Fable.Python exists. + ## When to Use Fable.Python Fable.Python is a great choice when: diff --git a/chapters/pydantic.fs b/chapters/pydantic.fs index 8c13dac..d5e35e1 100644 --- a/chapters/pydantic.fs +++ b/chapters/pydantic.fs @@ -1,22 +1,63 @@ (** # Pydantic Interop +## What is Pydantic? + [Pydantic](https://docs.pydantic.dev/) is Python's most popular data validation -library. Fable v5 introduces new attributes that make F# and Pydantic work -together seamlessly. +library. It's the de facto standard for modern Python APIs - FastAPI, LangChain, +and countless other frameworks rely on it. + +Pydantic gives you: + +- **Runtime type validation** - Catch bad data before it causes problems +- **Automatic serialization** - JSON/dict conversion built-in +- **Schema generation** - OpenAPI/JSON Schema for free +- **IDE support** - Full autocomplete from type hints + +Fable v5 introduces attributes that make F# and Pydantic work together seamlessly. -## The Decorator Attribute +## Creating Models in F`#` -The `Py.Decorator` attribute lets you add Python decorators to F# types: +### Using ClassAttributes + +The `Py.ClassAttributes` attribute controls how class members are generated, +which is essential for Pydantic compatibility: *) (*** hide ***) module Pydantic open Fable.Core -open Fable.Python +open Fable.Python.Pydantic + +(** +*) + +[] +type User() = + inherit BaseModel() + member val Name: string = "" with get, set + member val Age: int = 0 with get, set + member val Email: string option = None with get, set (** +This generates clean Pydantic code: + +```python +from pydantic import BaseModel + +class User(BaseModel): + Name: str = "" + Age: int = 0 + Email: str | None = None +``` + +The `style = Attributes` tells Fable to generate class-level attributes (what +Pydantic expects) rather than instance attributes set in `__init__`. + +### The Decorator Attribute + +For simpler cases like dataclasses, use `Py.Decorate`: *) [] @@ -27,19 +68,12 @@ type Person = { (** This generates: +*) -```python -@dataclasses.dataclass -class Person: - name: str - age: int32 -``` - -The decorator is applied directly to the generated Python class! - -## Decorator with Parameters +(*** include-python: Person ***) -You can also pass parameters to decorators: +(** +You can pass parameters to decorators: *) [] @@ -49,84 +83,251 @@ type Point = { } (** -This generates: - -```python -@dataclasses.dataclass(frozen=True, slots=True) -class Point: - x: float - y: float -``` +The `frozen=True` makes instances immutable (matching F# record semantics). -The `frozen=True` makes instances immutable (matching F# record semantics), -and `slots=True` optimizes memory usage. +## Fields and Validation -## ClassAttributes for Pydantic - -The `Py.ClassAttributes` attribute controls how class members are generated, -which is essential for Pydantic compatibility: +Pydantic's `Field()` function lets you add constraints and metadata to fields. +The `Fable.Python.Pydantic` module provides typed helpers: *) -[] -type BaseModel() = class end - [] -type PydanticUser() = +type Product() = inherit BaseModel() + member val Name: string = "" with get, set - member val Age: int = 0 with get, set - member val Email: string option = None with get, set + + // Field with description + member val Description: Field = + Field.Description "Product description" with get, set + + // Field with numeric constraints + member val Price: Field = + Field.Ge 0.0 with get, set // price >= 0 + + // Field with string constraints + member val Sku: Field = + Field.Pattern "^[A-Z]{2}-[0-9]{4}$" with get, set // e.g., "AB-1234" (** -This generates clean Pydantic code: +Available field constraints: + +| Function | Constraint | +| ------------------- | --------------------- | +| `Field.Gt` | Greater than | +| `Field.Ge` | Greater than or equal | +| `Field.Lt` | Less than | +| `Field.Le` | Less than or equal | +| `Field.MinLength` | Minimum string length | +| `Field.MaxLength` | Maximum string length | +| `Field.Pattern` | Regex pattern | +| `Field.Default` | Default value | +| `Field.Description` | Field description | + +## Importing Python-Defined Models + +Sometimes you need to use Pydantic models defined in Python - perhaps from an +OpenAPI generator, a Python team, or an existing codebase. Here's the pattern: + +Given a Python model in `models.py`: ```python from pydantic import BaseModel -class PydanticUser(BaseModel): - Age: int32 = int32.ZERO - Email: str | None - Name: str = "" +class Customer(BaseModel): + id: int + name: str + email: str | None = None ``` -You get all of Pydantic's features: +Create F# bindings: +*) + +/// Customer model imported from models.py +[] +type Customer = + abstract id: int with get, set + abstract name: string with get, set + abstract email: string option with get, set -- **Automatic validation** - Type checking at runtime -- **Serialization** - JSON/dict conversion built-in -- **Schema generation** - OpenAPI/JSON Schema support -- **IDE support** - Full autocomplete and type hints +/// Helper module for creating instances +[] +module Customer = + [] + [] + let create (id: int) (name: string) (email: string option) : Customer = nativeOnly -## Why This Matters +(** +Now you can use the Python model from F# with full type safety: +*) -This interop enables powerful patterns: +let customer = Customer.create 1 "Alice" (Some "alice@example.com") -1. **Define models in F#** with full type safety and pattern matching -2. **Generate Python classes** that integrate with the Python ecosystem -3. **Use Pydantic validation** in FastAPI, LangChain, and other frameworks -4. **Publish to PyPI** - Your F# types become Python packages +let showCustomer (c: Customer) = + printfn "Customer %d: %s" c.id c.name + match c.email with + | Some email -> printfn " Email: %s" email + | None -> printfn " No email on file" + +(** +This pattern is useful when you want to: + +- Use models generated from OpenAPI specs +- Integrate with an existing Python codebase +- Share models between Python and F# code + +## Type Mappings + +F# types map naturally to Python/Pydantic types: -## F# Option to Python Union +| F# Type | Python Type | Notes | +| ----------- | ------------ | ------------------------------ | +| `string` | `str` | | +| `int` | `int` | | +| `float` | `float` | | +| `bool` | `bool` | | +| `'T option` | `T \| None` | Modern union syntax | +| `'T list` | `list[T]` | | +| `'T array` | `list[T]` | | +| Record | `class` | With `@dataclass` or BaseModel | +| DU | Tagged class | See below | + +### F# Option to Python Union Notice how `string option` becomes `str | None` in Python. Fable v5 uses modern Python union syntax for optional types, making the generated code feel native to Python developers. -## Example: FastAPI Integration +## Serialization + +Pydantic models have built-in serialization methods: +*) -These Pydantic models can be used directly with FastAPI: +let serializationExample () = + let user = User() + user.Name <- "Alice" + user.Age <- 30 + user.Email <- Some "alice@example.com" -```python -from fastapi import FastAPI -from your_fsharp_module import PydanticUser + // Convert to dictionary + let dict = user.model_dump() -app = FastAPI() + // Convert to JSON string + let json = user.model_dump_json() -@app.post("/users") -def create_user(user: PydanticUser) -> PydanticUser: - # Pydantic validates the request automatically - return user + // Pretty-printed JSON + let prettyJson = user.model_dump_json_indented 2 + + printfn "JSON: %s" json + +(** +The `model_dump()` and `model_dump_json()` methods are available on any +class that inherits from `BaseModel`. + +## The DTO Boundary Pattern + +A Pydantic model is not your domain - it's a **Data Transfer Object (DTO)**. +This distinction is important for well-architected applications: + +```text +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ F# Domain │ →→→ │ Pydantic DTO │ →→→ │ JSON / API │ +│ │ map │ │ dump │ │ +│ UserId (Guid) │ │ Id: str │ │ "id": "a1b2.." │ +│ Age: int32 │ │ Age: int │ │ "age": 42 │ +│ Balance: Money │ │ Amount: float │ │ "amount": 3.14 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ ``` +### Different Concerns, Different Types + +| Concern | Domain Types | Transfer Types | +| ---------- | -------------------------- | ---------------------------- | +| Purpose | Model business logic | Cross-boundary communication | +| Semantics | Rich (overflow, precision) | Simple (JSON-compatible) | +| Validation | Business rules | Schema conformance | +| Stability | Can evolve internally | API contract | + +### Domain Types vs DTO Types +*) + +/// Domain model - uses precise F# types +type UserId = UserId of System.Guid + +type Money = { Amount: decimal; Currency: string } + +type DomainUser = { + Id: UserId + Name: string + Age: int32 // Bounded, wrapping arithmetic + Balance: Money +} + +/// DTO - uses Python-native types for serialization +[] +type UserDTO() = + inherit BaseModel() + member val Id: string = "" with get, set + member val Name: string = "" with get, set + member val Age: int = 0 with get, set + member val BalanceAmount: float = 0.0 with get, set + member val BalanceCurrency: string = "" with get, set + +(** +### The Mapping Layer + +Explicit transformation between domain and DTO: +*) + +module UserMapping = + let toDTO (user: DomainUser) : UserDTO = + let dto = UserDTO() + dto.Id <- match user.Id with UserId guid -> string guid + dto.Name <- user.Name + dto.Age <- int user.Age + dto.BalanceAmount <- float user.Balance.Amount + dto.BalanceCurrency <- user.Balance.Currency + dto + + let fromDTO (dto: UserDTO) : Result = + try + Ok { + Id = UserId (System.Guid.Parse dto.Id) + Name = dto.Name + Age = int32 dto.Age + Balance = { + Amount = decimal dto.BalanceAmount + Currency = dto.BalanceCurrency + } + } + with ex -> + Error ex.Message + +(** +### Why This Pattern? + +The "boilerplate" of separate DTO types is actually valuable: + +1. **Serialization just works** - DTOs use Python-native types +2. **Domain integrity preserved** - Your `int32` still has proper wrapping behavior +3. **Clear boundaries** - The mapping layer handles validation and transformation +4. **API evolution** - DTOs can change independently of domain types + +The visual difference between F# records and Pydantic classes is a **feature** - +it's a speed bump that makes you think about the boundary you're crossing. + +## Why This Matters + +This interop enables powerful patterns: + +1. **Define models in F#** with full type safety and pattern matching +2. **Generate Python classes** that integrate with the Python ecosystem +3. **Use Pydantic validation** in FastAPI, LangChain, and other frameworks +4. **Publish to PyPI** - Your F# types become Python packages + You get the best of both worlds: F#'s type safety during development, and Python's rich ecosystem at runtime. + +In the next chapter, we'll see how to use these Pydantic models with FastAPI +to build type-safe web APIs. *) diff --git a/docs/async-programming.md b/docs/async-programming.md index e0a7bd5..f52774b 100644 --- a/docs/async-programming.md +++ b/docs/async-programming.md @@ -123,24 +123,13 @@ The `task` computation expression in .NET creates *hot* tasks that start immedia However, when compiled to Python via Fable, tasks become Python coroutines - which are *cold* just like Python's native `async def` functions. -In Fable v5, tasks compile to Python's native `async def` syntax, enabling seamless -integration with frameworks like FastAPI. +A key improvement in Fable v5 is that `task { }` now compiles to Python's native +`async def` syntax. Previously, Fable generated regular functions returning `Awaitable[T]`, +which frameworks like FastAPI couldn't recognize as async endpoints. ```fsharp open System.Threading.Tasks -let fetchDataTask () = - task { - do! Task.Delay 1000 - return "data from task" - } -``` - -### Fable v5: Native Python Async - -A key improvement in Fable v5 is that `task { }` now compiles to Python's `async def`: - -```fsharp let processItemTask (item: string) = task { do! Task.Delay 100 @@ -149,15 +138,8 @@ let processItemTask (item: string) = ``` This generates: - -```python -async def process_item_task(item: str) -> str: - await asyncio.sleep(0.1) - return item.upper() -``` - -Previously, Fable generated regular functions returning `Awaitable[T]`, which frameworks -like FastAPI couldn't recognize as async endpoints. Now the integration is seamless. + +Now frameworks like FastAPI can detect and handle these as proper async endpoints. ### Task vs Async: Key Differences @@ -185,6 +167,12 @@ like FastAPI couldn't recognize as async endpoints. Now the integration is seaml ### Working with Tasks ```fsharp +let fetchDataTask () = + task { + do! Task.Delay 100 // Do some async work + return "data from task" + } + let taskExample () = task { let! result = fetchDataTask () @@ -221,13 +209,13 @@ In Python, this generates: ```python def simple_async(__unit: None = None) -> Async[int32]: - def _arrow61(__unit: None = None) -> Async[int32]: - def _arrow60(__unit: None = None) -> Async[int32]: + def _arrow58(__unit: None = None) -> Async[int32]: + def _arrow57(__unit: None = None) -> Async[int32]: return singleton.Return(int32(42)) - return singleton.Bind(sleep(int32(500)), _arrow60) + return singleton.Bind(sleep(int32(500)), _arrow57) - return singleton.Delay(_arrow61) + return singleton.Delay(_arrow58) ``` ### Tasks → Native async def @@ -244,6 +232,30 @@ let simpleTask () = In Python, this generates: +### Running Tasks from F`#` + +To run a task and get its result in F#: + +```fsharp +let runTaskExample () = + let tsk = simpleTask () + + // Block and wait for result + let result = tsk.GetAwaiter().GetResult() + printfn $"Got: {result}" +``` + +You can also await tasks inside other tasks: + +```fsharp +let chainedTasks () = + task { + let! first = simpleTask () + let! second = simpleTask () + return first + second + } +``` + ### Running in Python's Event Loop When your compiled Python code runs, you'll need an event loop. For scripts: @@ -251,7 +263,6 @@ When your compiled Python code runs, you'll need an event loop. For scripts: ```python import asyncio -# If using task-based code async def main(): result = await simple_task() print(result) diff --git a/docs/fable-v5.md b/docs/fable-v5.md index d0517db..f9bf99c 100644 --- a/docs/fable-v5.md +++ b/docs/fable-v5.md @@ -38,6 +38,11 @@ in **Rust** using PyO3. The motivation is **correctness**, not performance: - **Fixed-size arrays** - No more Python list quirks for byte streams - **Reliable numerics** - Fable 4's pure Python numerics were a constant source of bugs +While Rust is fast, don't expect dramatic speedups for typical F# code. Many F# +functions are higher-order and callback to Python - `List.map`, `List.filter`, +`Seq.fold`, etc. all invoke your Python lambdas. The Rust core handles the +data structures correctly; your code still runs at Python speed. + ## fable-library via PyPI Before Fable v5, the runtime was bundled in the NuGet package and copied @@ -51,6 +56,19 @@ pip install fable-library uv add fable-library ``` +For projects, pin your dependencies in `pyproject.toml`. For stable releases use +a minimum version constraint: + +```toml +dependencies = ["fable-library>=5.0.0"] +``` + +For alpha/beta releases, pin the exact version to avoid surprises: + +```toml +dependencies = ["fable-library==5.0.0a21"] +``` + This makes dependency management much simpler and follows Python conventions. ## Test Coverage @@ -71,10 +89,10 @@ To use Fable v5, install the alpha CLI: ```bash # Install Fable 5 CLI -dotnet tool install fable --version 5.0.0-alpha.17 +dotnet tool install fable --version 5.0.0-alpha.21 # Add Fable.Core to your project -dotnet add package Fable.Core --version 5.0.0-beta.2 +dotnet add package Fable.Core --version 5.0.0-beta.4 # Install the Python runtime uv add fable-library==5.0.0a17 diff --git a/docs/getting-started.md b/docs/getting-started.md index bda6f72..8e5eff5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,8 @@ running as Python. You'll need: -- [.NET SDK](https://dotnet.microsoft.com/download) (6.0 or later) +- [.NET SDK](https://dotnet.microsoft.com/download) (6.0 or later. We recommend + installing the latest LTS version, currently .NET 10 - [Python 3.12+](https://www.python.org/downloads/) (Fable targets Python 3.12 or higher) ## Project Setup diff --git a/docs/introduction.md b/docs/introduction.md index ae4bb37..c544df0 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -20,6 +20,10 @@ F# is a functional-first language with powerful features like: With Fable.Python, you get all these benefits while targeting the Python ecosystem. +Python is the [most popular programming language](https://www.tiobe.com/tiobe-index/) +in the world. And no matter what you think of Python, it will always be the second +best language for everything. That ubiquity is exactly why Fable.Python exists. + ## When to Use Fable.Python Fable.Python is a great choice when: diff --git a/docs/pydantic.md b/docs/pydantic.md index 0ac03e5..5707d65 100644 --- a/docs/pydantic.md +++ b/docs/pydantic.md @@ -1,12 +1,53 @@ # Pydantic Interop +## What is Pydantic? + [Pydantic](https://docs.pydantic.dev/) is Python's most popular data validation -library. Fable v5 introduces new attributes that make F# and Pydantic work -together seamlessly. +library. It's the de facto standard for modern Python APIs - FastAPI, LangChain, +and countless other frameworks rely on it. + +Pydantic gives you: + +- **Runtime type validation** - Catch bad data before it causes problems +- **Automatic serialization** - JSON/dict conversion built-in +- **Schema generation** - OpenAPI/JSON Schema for free +- **IDE support** - Full autocomplete from type hints + +Fable v5 introduces attributes that make F# and Pydantic work together seamlessly. + +## Creating Models in F`#` + +### Using ClassAttributes + +The `Py.ClassAttributes` attribute controls how class members are generated, +which is essential for Pydantic compatibility: + +```fsharp +[] +type User() = + inherit BaseModel() + member val Name: string = "" with get, set + member val Age: int = 0 with get, set + member val Email: string option = None with get, set +``` + +This generates clean Pydantic code: + +```python +from pydantic import BaseModel + +class User(BaseModel): + Name: str = "" + Age: int = 0 + Email: str | None = None +``` + +The `style = Attributes` tells Fable to generate class-level attributes (what +Pydantic expects) rather than instance attributes set in `__init__`. -## The Decorator Attribute +### The Decorator Attribute -The `Py.Decorator` attribute lets you add Python decorators to F# types: +For simpler cases like dataclasses, use `Py.Decorate`: ```fsharp [] @@ -19,17 +60,13 @@ type Person = { This generates: ```python -@dataclasses.dataclass -class Person: +@dataclass(eq=False, repr=False, slots=True) +class Person(Record): name: str age: int32 ``` -The decorator is applied directly to the generated Python class! - -## Decorator with Parameters - -You can also pass parameters to decorators: +You can pass parameters to decorators: ```fsharp [] @@ -39,83 +76,224 @@ type Point = { } ``` -This generates: +The `frozen=True` makes instances immutable (matching F# record semantics). -```python -@dataclasses.dataclass(frozen=True, slots=True) -class Point: - x: float - y: float -``` - -The `frozen=True` makes instances immutable (matching F# record semantics), -and `slots=True` optimizes memory usage. +## Fields and Validation -## ClassAttributes for Pydantic - -The `Py.ClassAttributes` attribute controls how class members are generated, -which is essential for Pydantic compatibility: +Pydantic's `Field()` function lets you add constraints and metadata to fields. +The `Fable.Python.Pydantic` module provides typed helpers: ```fsharp -[] -type BaseModel() = class end - [] -type PydanticUser() = +type Product() = inherit BaseModel() + member val Name: string = "" with get, set - member val Age: int = 0 with get, set - member val Email: string option = None with get, set + + // Field with description + member val Description: Field = + Field.Description "Product description" with get, set + + // Field with numeric constraints + member val Price: Field = + Field.Ge 0.0 with get, set // price >= 0 + + // Field with string constraints + member val Sku: Field = + Field.Pattern "^[A-Z]{2}-[0-9]{4}$" with get, set // e.g., "AB-1234" ``` -This generates clean Pydantic code: +Available field constraints: + +| Function | Constraint | +| ------------------- | --------------------- | +| `Field.Gt` | Greater than | +| `Field.Ge` | Greater than or equal | +| `Field.Lt` | Less than | +| `Field.Le` | Less than or equal | +| `Field.MinLength` | Minimum string length | +| `Field.MaxLength` | Maximum string length | +| `Field.Pattern` | Regex pattern | +| `Field.Default` | Default value | +| `Field.Description` | Field description | + +## Importing Python-Defined Models + +Sometimes you need to use Pydantic models defined in Python - perhaps from an +OpenAPI generator, a Python team, or an existing codebase. Here's the pattern: + +Given a Python model in `models.py`: ```python from pydantic import BaseModel -class PydanticUser(BaseModel): - Age: int32 = int32.ZERO - Email: str | None - Name: str = "" +class Customer(BaseModel): + id: int + name: str + email: str | None = None ``` -You get all of Pydantic's features: +Create F# bindings: -- **Automatic validation** - Type checking at runtime -- **Serialization** - JSON/dict conversion built-in -- **Schema generation** - OpenAPI/JSON Schema support -- **IDE support** - Full autocomplete and type hints +```fsharp +/// Customer model imported from models.py +[] +type Customer = + abstract id: int with get, set + abstract name: string with get, set + abstract email: string option with get, set + +/// Helper module for creating instances +[] +module Customer = + [] + [] + let create (id: int) (name: string) (email: string option) : Customer = nativeOnly +``` -## Why This Matters +Now you can use the Python model from F# with full type safety: -This interop enables powerful patterns: +```fsharp +let customer = Customer.create 1 "Alice" (Some "alice@example.com") -1. **Define models in F#** with full type safety and pattern matching -2. **Generate Python classes** that integrate with the Python ecosystem -3. **Use Pydantic validation** in FastAPI, LangChain, and other frameworks -4. **Publish to PyPI** - Your F# types become Python packages +let showCustomer (c: Customer) = + printfn "Customer %d: %s" c.id c.name + match c.email with + | Some email -> printfn " Email: %s" email + | None -> printfn " No email on file" +``` -## F# Option to Python Union +This pattern is useful when you want to: + +- Use models generated from OpenAPI specs +- Integrate with an existing Python codebase +- Share models between Python and F# code + +## Type Mappings + +F# types map naturally to Python/Pydantic types: + +| F# Type | Python Type | Notes | +| ----------- | ------------ | ------------------------------ | +| `string` | `str` | | +| `int` | `int` | | +| `float` | `float` | | +| `bool` | `bool` | | +| `'T option` | `T \| None` | Modern union syntax | +| `'T list` | `list[T]` | | +| `'T array` | `list[T]` | | +| Record | `class` | With `@dataclass` or BaseModel | +| DU | Tagged class | See below | + +### F# Option to Python Union Notice how `string option` becomes `str | None` in Python. Fable v5 uses modern Python union syntax for optional types, making the generated code feel native to Python developers. -## Example: FastAPI Integration +## Serialization -These Pydantic models can be used directly with FastAPI: +Pydantic models have built-in serialization methods: -```python -from fastapi import FastAPI -from your_fsharp_module import PydanticUser +```fsharp +let serializationExample () = + let user = User() + user.Name <- "Alice" + user.Age <- 30 + user.Email <- Some "alice@example.com" + + // Convert to dictionary + let dict = user.model_dump() + + // Convert to JSON string + let json = user.model_dump_json() + + // Pretty-printed JSON + let prettyJson = user.model_dump_json_indented 2 + + printfn "JSON: %s" json +``` + +The `model_dump()` and `model_dump_json()` methods are available on any +class that inherits from `BaseModel`. + +## The DTO Boundary Pattern + +A Pydantic model is not your domain - it's a **Data Transfer Object (DTO)**. +This distinction is important for well-architected applications: + +```text +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ F# Domain │ →→→ │ Pydantic DTO │ →→→ │ JSON / API │ +│ │ map │ │ dump │ │ +│ UserId (Guid) │ │ Id: str │ │ "id": "a1b2.." │ +│ Age: int32 │ │ Age: int │ │ "age": 42 │ +│ Balance: Money │ │ Amount: float │ │ "amount": 3.14 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Different Concerns, Different Types + +| Concern | Domain Types | Transfer Types | +| ---------- | -------------------------- | ---------------------------- | +| Purpose | Model business logic | Cross-boundary communication | +| Semantics | Rich (overflow, precision) | Simple (JSON-compatible) | +| Validation | Business rules | Schema conformance | +| Stability | Can evolve internally | API contract | -app = FastAPI() +### Domain Types vs DTO Types -@app.post("/users") -def create_user(user: PydanticUser) -> PydanticUser: - # Pydantic validates the request automatically - return user +```fsharp +/// Domain model - uses precise F# types +type UserId = UserId of System.Guid + +type Money = { Amount: decimal; Currency: string } + +type DomainUser = { + Id: UserId + Name: string + Age: int32 // Bounded, wrapping arithmetic + Balance: Money +} + +/// DTO - uses Python-native types for serialization +[] +type UserDTO() = + inherit BaseModel() + member val Id: string = "" with get, set + member val Name: string = "" with get, set + member val Age: int = 0 with get, set + member val BalanceAmount: float = 0.0 with get, set + member val BalanceCurrency: string = "" with get, set ``` +### The Mapping Layer + +Explicit transformation between domain and DTO: + +### Why This Pattern? + +The "boilerplate" of separate DTO types is actually valuable: + +1. **Serialization just works** - DTOs use Python-native types +2. **Domain integrity preserved** - Your `int32` still has proper wrapping behavior +3. **Clear boundaries** - The mapping layer handles validation and transformation +4. **API evolution** - DTOs can change independently of domain types + +The visual difference between F# records and Pydantic classes is a **feature** - +it's a speed bump that makes you think about the boundary you're crossing. + +## Why This Matters + +This interop enables powerful patterns: + +1. **Define models in F#** with full type safety and pattern matching +2. **Generate Python classes** that integrate with the Python ecosystem +3. **Use Pydantic validation** in FastAPI, LangChain, and other frameworks +4. **Publish to PyPI** - Your F# types become Python packages + You get the best of both worlds: F#'s type safety during development, and Python's rich ecosystem at runtime. + +In the next chapter, we'll see how to use these Pydantic models with FastAPI +to build type-safe web APIs.