From 991fe98e04a83ad61b8907b6e79054cedd69760b Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 11 Dec 2025 00:47:33 +0100 Subject: [PATCH 1/4] feat: refactor as AST parser --- .vscode/settings.json | 2 + Fable.Literate/App.fs | 460 +++++++++++++++++++ Fable.Literate/Fable.Literate.fsproj | 20 + Fable.Literate/Python.fs | 125 ++++++ chapters/compatibility.fs | 15 +- chapters/interop.fs | 16 +- chapters/introduction.fs | 5 +- docs/blogpost.md | 640 ++++++++------------------- docs/compatibility.md | 19 +- docs/fable-literate.md | 197 +++++++++ docs/fabletext.md | 563 ----------------------- docs/getting-started.md | 8 +- docs/python.md | 32 +- justfile | 32 +- tools/fabletext.fs | 468 -------------------- tools/fabletext.fsproj | 18 - 16 files changed, 1064 insertions(+), 1556 deletions(-) create mode 100644 Fable.Literate/App.fs create mode 100644 Fable.Literate/Fable.Literate.fsproj create mode 100644 Fable.Literate/Python.fs create mode 100644 docs/fable-literate.md delete mode 100644 docs/fabletext.md delete mode 100644 tools/fabletext.fs delete mode 100644 tools/fabletext.fsproj diff --git a/.vscode/settings.json b/.vscode/settings.json index a8dcb19..9de623a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "cSpell.words": [ + "destructures", "elif", + "eprintln", "fabletext", "Fantomas", "fastapi", diff --git a/Fable.Literate/App.fs b/Fable.Literate/App.fs new file mode 100644 index 0000000..40b8bea --- /dev/null +++ b/Fable.Literate/App.fs @@ -0,0 +1,460 @@ +(** +# Fable.Literate: The Strange Loop + +You've made it to the end - and here's where things get delightfully meta. + +**The blog post you're reading was generated by the code in this chapter.** + +This is Fable.Literate, a literate programming converter inspired by +[jupytext](https://github.com/mwouts/jupytext) and +[FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/). +It's written in F#, compiled to Python via Fable, and it processes the +`.fs` files that make up this blog - including itself. + +The chain goes like this: + +1. Each chapter is an F# file with embedded Markdown comments +2. Fable compiles the F# to Python +3. Fable.Literate (this code, running as Python) extracts the documentation +4. The output is the Markdown you're reading right now + +It's a strange loop - the snake eating its tail. And it proves that +Fable.Python isn't just a toy: you're looking at a real project that works. + +## How It Works + +The converter follows a compiler-like architecture with three phases: + +1. **Parse**: Convert source lines into a Block AST +2. **Transform**: Filter hidden blocks, resolve Python includes +3. **Print**: Render the AST as Markdown + +The input syntax: + +- Lines inside `(** ... *)` blocks become Markdown +- F# code outside those blocks is wrapped in fenced code blocks +- `(*** hide ***)` sections are excluded from output +- `(*** include-python: symbol1, symbol2 ***)` extracts generated Python code + +*) + +(*** hide ***) +open System +open Fable.Core +open Fable.Literate.Python + +(** +## AST Types + +The document is represented as a list of blocks. Each block represents +a distinct section of the literate source file: +*) + +/// A single block in the document AST. +type Block = + /// Raw markdown content from (** ... *) blocks + | Markdown of content: string + /// F# code that should be wrapped in fenced blocks + | FSharpCode of lines: string list + /// Hidden content - filtered out by Transform.filterHidden + | Hidden of lines: string list + /// Unresolved directive to include Python symbols (from parsing) + /// Resolved to PythonCode by Transform.resolvePythonIncludes + | IncludePython of symbols: string list + /// Resolved Python code (after Transform.resolvePythonIncludes) + | PythonCode of content: string + +/// A parsed document is a list of blocks. +type Document = Block list + +(** +## Utils Module + +Utility functions for naming conversion and line classification: +*) + +module Utils = + /// List of contributors to thank (Fable-style). + let contributors = [| + "@dbrattli" + "@alfonsogarciacaro" + "@ncave" + "@MangelMaxime" + "@claude-code 🤖" + |] + + /// Returns a random contributor from the list. + let randomContributor () : string = + let rnd = Random() + contributors.[rnd.Next(contributors.Length)] + + /// Converts camelCase to snake_case for the function part. + let private toSnakeCase (name: string) : string = + if name.Length > 0 && Char.IsLower(name.[0]) then + System.Text.RegularExpressions.Regex.Replace( + name, + "[a-z]?[A-Z]", + fun m -> + if m.Value.Length = 1 then + m.Value.ToLowerInvariant() + else + m.Value.Substring(0, 1) + "_" + m.Value.Substring(1, 1).ToLowerInvariant() + ) + else + name + + /// Converts F# symbol reference to Python naming. + /// - "Module.func" -> "Module_func" (Fable keeps camelCase for module functions) + /// - "func" -> "func" (with snake_case conversion for top-level) + let toPythonNaming (name: string) : string = + match name.Split('.') with + | [| moduleName; funcName |] -> moduleName + "_" + funcName // Module functions stay camelCase + | _ -> toSnakeCase name // Top-level functions get snake_case + + /// Parses a comma-separated list of symbols from an include-python directive. + let parseSymbolList (directive: string) : string list = + // Extract content between "(*** include-python:" and "***)" + let start = "(*** include-python:".Length + let endPos = directive.LastIndexOf("***)") + + if endPos > start then + directive.Substring(start, endPos - start).Trim() + |> fun s -> s.Split(',') + |> Array.map (fun s -> s.Trim()) + |> Array.filter (fun s -> s.Length > 0) + |> Array.toList + else + [] + + /// Active pattern for classifying source lines. + /// - `HideCmd`: The (*** hide ***) directive + /// - `IncludePythonCmd symbols`: The (*** include-python: sym1, sym2 ***) directive + /// - `MarkdownSingle content`: Single-line markdown (** content *) + /// - `MarkdownOpen content`: Start of markdown block, possibly with content + /// - `MarkdownClose`: End of markdown block *) + /// - `Content`: Any other line + let (|HideCmd|IncludePythonCmd|MarkdownSingle|MarkdownOpen|MarkdownClose|Content|) (line: string) = + let trimmed = line.Trim() + + match trimmed with + | "(*** hide ***)" -> HideCmd + | s when s.StartsWith("(*** include-python:") && s.EndsWith("***)") -> IncludePythonCmd(parseSymbolList s) + | s when s.StartsWith("(**") && s.EndsWith("*)") && s.Length > 5 -> + MarkdownSingle(s.Substring(3, s.Length - 5).Trim()) + | s when s.StartsWith("(**") -> + let content = if s.Length > 3 then s.Substring(3).Trim() else "" + MarkdownOpen content + | "*)" -> MarkdownClose + | _ -> Content + +open Utils + +(** +## Parser Module + +The parser converts source lines into a Block AST using a fold: +*) + +module Parser = + /// Internal state for block accumulation during parsing. + type private ParserState = + | CollectingMarkdown of lines: string list + | CollectingCode of lines: string list + | CollectingHidden of lines: string list + | Ready + + /// Parse context threaded through the fold. + type private ParseContext = { + State: ParserState + Blocks: Block list // Accumulated blocks (in reverse) + } + + /// Flush current state to a block if non-empty. + let private flushState (ctx: ParseContext) : ParseContext = + match ctx.State with + | Ready -> ctx + | CollectingMarkdown [] -> { ctx with State = Ready } + | CollectingMarkdown lines -> + let content = lines |> List.rev |> String.concat "\n" + + { + State = Ready + Blocks = Markdown content :: ctx.Blocks + } + | CollectingCode [] -> { ctx with State = Ready } + | CollectingCode lines -> { + State = Ready + Blocks = FSharpCode(List.rev lines) :: ctx.Blocks + } + | CollectingHidden [] -> { ctx with State = Ready } + | CollectingHidden lines -> { + State = Ready + Blocks = Hidden(List.rev lines) :: ctx.Blocks + } + + /// Process a single line, updating parser state. + let private parseLine (ctx: ParseContext) (line: string) : ParseContext = + match ctx.State, line with + // Hide directive - flush current, start hidden collection + | _, HideCmd -> + let flushed = flushState ctx + { flushed with State = CollectingHidden [] } + + // Include-python directive - flush and emit as block + | (CollectingCode _ | CollectingHidden _ | Ready), IncludePythonCmd symbols -> + let flushed = flushState ctx + { flushed with Blocks = IncludePython symbols :: flushed.Blocks } + + // Single-line markdown + | (CollectingCode _ | CollectingHidden _ | Ready), MarkdownSingle content -> + let flushed = flushState ctx + { flushed with Blocks = Markdown content :: flushed.Blocks } + + // Start markdown block + | (CollectingCode _ | CollectingHidden _ | Ready), MarkdownOpen content -> + let flushed = flushState ctx + let initial = if content.Length > 0 then [ content ] else [] + { flushed with State = CollectingMarkdown initial } + + // End markdown block + | CollectingMarkdown _, MarkdownClose -> flushState ctx + + // Content in markdown + | CollectingMarkdown lines, Content -> { ctx with State = CollectingMarkdown(line :: lines) } + + // Content in code mode + | CollectingCode lines, Content -> { ctx with State = CollectingCode(line :: lines) } + | Ready, Content -> { ctx with State = CollectingCode [ line ] } + + // Content in hidden mode + | CollectingHidden lines, Content -> { ctx with State = CollectingHidden(line :: lines) } + | CollectingHidden lines, MarkdownClose -> ctx // Stay hidden + + // Ignore invalid transitions + | CollectingMarkdown _, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx + | (CollectingCode _ | Ready), MarkdownClose -> ctx + + /// Parse lines into a document AST. + let parse (lines: string seq) : Document = + let initial = { + State = Ready + Blocks = [] + } + + lines + |> Seq.fold parseLine initial + |> flushState + |> fun ctx -> List.rev ctx.Blocks + +(** +## Transform Module + +Pure transformations on the document AST: +*) + +module Transform = + /// Boilerplate prefixes that should be excluded from code blocks. + let boilerplatePrefixes = [ "module "; "namespace " ] + + /// Remove Hidden blocks from the document. + let filterHidden (doc: Document) : Document = + doc + |> List.filter (function + | Hidden _ -> false + | _ -> true) + + /// Check if code lines are empty or boilerplate-only. + let private isBoilerplate (lines: string list) : bool = + let code = lines |> String.concat "\n" |> (fun s -> s.Trim()) + + String.IsNullOrWhiteSpace code + || boilerplatePrefixes |> List.exists (fun prefix -> code.Trim().StartsWith prefix) + + /// Remove empty or boilerplate-only code blocks. + let filterBoilerplate (doc: Document) : Document = + doc + |> List.filter (function + | FSharpCode lines when isBoilerplate lines -> false + | _ -> true) + + /// Resolve IncludePython blocks to actual Python code blocks. + let resolvePythonIncludes (pythonContent: string option) (doc: Document) : Document = + doc + |> List.map (function + | IncludePython symbols -> + let symbolList = String.concat ", " symbols + + match pythonContent with + | Some content -> + let extracted = extractSymbols toPythonNaming symbols content + + if String.IsNullOrWhiteSpace extracted then + Markdown $"" + else + PythonCode extracted + | None -> Markdown $"" + | other -> other) + +(** +## MarkdownPrinter Module + +Renders the document AST to markdown: +*) + +module MarkdownPrinter = + /// Render a single block to markdown. + let private printBlock (block: Block) : string = + match block with + | Markdown content -> content + "\n" + | FSharpCode lines -> + let code = lines |> String.concat "\n" |> (fun s -> s.Trim()) + "\n```fsharp\n" + code + "\n```\n\n" + | PythonCode content -> "\n```python\n" + content + "\n```\n\n" + | IncludePython symbols -> + // Unresolved - should have been transformed + let symbolList = String.concat ", " symbols + "\n\n" + | Hidden _ -> "" // Should have been filtered + + /// Render a document to markdown string. + let printMarkdown (doc: Document) : string = + doc |> List.map printBlock |> String.concat "" + + /// Increases all markdown header levels by one (# becomes ##, etc.). + /// Preserves headers inside fenced code blocks. + let adjustHeaderLevels (markdown: string) : string = + let lines = markdown.Split('\n') + + let folder (inCodeBlock, acc) (line: string) = + match line with + | s when s.StartsWith "```" -> not inCodeBlock, line :: acc + | _ when inCodeBlock -> inCodeBlock, line :: acc + | s when s.StartsWith "#" -> inCodeBlock, ("#" + line) :: acc + | _ -> inCodeBlock, line :: acc + + lines |> Array.fold folder (false, []) |> snd |> List.rev |> String.concat "\n" + +(** +## Pipeline Module + +Composes the phases into a complete pipeline: +*) + +module Pipeline = + /// Standard processing pipeline. + let standard (pythonContent: string option) (lines: string seq) : string = + lines + |> Parser.parse + |> Transform.filterHidden + |> Transform.filterBoilerplate + |> Transform.resolvePythonIncludes pythonContent + |> MarkdownPrinter.printMarkdown + +(** +## Including Generated Python Code + +One of Fable.Literate's unique features is the ability to show the generated Python +alongside the F# source. The include-python directive extracts specific symbols +from the transpiled output. + +When you pass `--python-file path` to Fable.Literate, it reads the transpiled +Python and extracts the named symbols (functions, classes, or variables). +This lets readers see exactly what Python code Fable generates from the F#. + +The extraction is smart about Python syntax: + +- It finds the symbol definition by matching patterns like def symbol or class symbol +- It walks backwards to include any decorators +- For multi-line definitions, it captures everything until the next top-level definition +- It stops before dunder methods to avoid pulling in too much + +For example, the extractSymbol function in F# generates this Python: +*) + +(*** include-python: extractSymbol ***) + +(** +## Main Entry Point + +Read the input file, convert it, and print the result: +*) + +/// Gets the value following a flag argument (e.g., --python-file path.py). +let getFlagValue (flag: string) (args: string[]) : string option = + // Find the index of the flag in args + args + |> Array.tryFindIndex ((=) flag) + // Return the next argument if it exists + |> Option.bind (fun i -> if i + 1 < args.Length then Some args.[i + 1] else None) + +/// Extracts positional arguments (file paths) from command line args. +/// Filters out flags (--foo) and their values (--python-file path.py). +let getPositionalArgs (args: string[]) : string[] = + let isFlag (arg: string) = arg.StartsWith "--" + let isValueOfFlag i = i > 0 && args.[i - 1] = "--python-file" + + // Pair each argument with its index + args + |> Array.indexed + // Keep only non-flags that aren't values of flags + |> Array.filter (fun (i, arg) -> not (isFlag arg) && not (isValueOfFlag i)) + // Extract just the argument strings + |> Array.map snd + +/// Main entry point. Converts a literate F# file to Markdown. +/// Use --increase-headers flag to bump all header levels by one. +/// Use --python-file to enable include-python directives. +[] +let main (args: string[]) = + let hasFlag flag = args |> Array.contains flag + let pythonFilePath = getFlagValue "--python-file" args + let files = getPositionalArgs args + + if files.Length < 1 then + printfn "Usage: python app.py [--increase-headers] [--python-file ] " + 1 + else + // Thanks to the contributor! (Fable-style) + eprintln $"Fable.Literate: Thanks to the contributor! {randomContributor ()}" + + // Load Python file content if provided + let pythonContent = pythonFilePath |> Option.map readFile + + let content = readFile files.[0] + let lines = content.Split('\n') + + // Pipeline: parse -> transform -> print + let markdown = lines |> Pipeline.standard pythonContent + + let output = + if hasFlag "--increase-headers" then + MarkdownPrinter.adjustHeaderLevels markdown + else + markdown + + printRaw output + 0 + +(** +## Building and Running + +```bash +# Transpile to Python +dotnet fable Fable.Literate/ --lang python -o output/Fable.Literate/ + +# Convert a literate file +python output/Fable.Literate/app.py chapters/introduction.fs > docs/introduction.md +``` + +That's it! A complete literate programming converter in under 200 lines of F#. + +## The Punchline + +If you're reading this, the code worked. + +This entire blog post - every chapter, every code example, every explanation - +was processed by the F# code you just read, compiled to Python, and output +as Markdown. The proof is in the reading. + +Welcome to Fable.Python. Now go build something. +*) diff --git a/Fable.Literate/Fable.Literate.fsproj b/Fable.Literate/Fable.Literate.fsproj new file mode 100644 index 0000000..f5d32a2 --- /dev/null +++ b/Fable.Literate/Fable.Literate.fsproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + preview + + + + + + + + + + + + + + diff --git a/Fable.Literate/Python.fs b/Fable.Literate/Python.fs new file mode 100644 index 0000000..3448ecf --- /dev/null +++ b/Fable.Literate/Python.fs @@ -0,0 +1,125 @@ +/// Python interop and extraction utilities for Fable.Literate. +module Fable.Literate.Python + +open System +open Fable.Python.Builtins +open Fable.Python.Sys + +// ============ Python Bindings ============ + +/// Reads the entire contents of a file as a string. +let readFile (path: string) : string = builtins.``open``(path).read () + +/// Prints a string to stdout without a trailing newline. +let printRaw (s: string) : unit = builtins.print (s, ``end`` = "") + +/// Prints a string to stderr with a trailing newline. +let eprintln (s: string) : unit = builtins.print (s, file = sys.stderr) + +// ============ Python Extract ============ + +/// Checks if a line starts a new top-level definition (not indented). +let isTopLevelDefinition (line: string) : bool = + let trimmed = line.Trim() + + not (String.IsNullOrWhiteSpace line) + && not (line.StartsWith " ") + && not (line.StartsWith "\t") + && not (line.StartsWith "#") + && not (trimmed = ")" || trimmed = "]" || trimmed = "}") + +/// Checks if a line is a decorator. +let isDecorator (line: string) : bool = line.TrimStart().StartsWith "@" + +/// Checks if a line is a dunder method definition. +let isDunderMethod (line: string) : bool = line.TrimStart().StartsWith "def __" + +/// Skips elements from the start of an array while the predicate is true. +let arraySkipWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = + match arr |> Array.tryFindIndex (predicate >> not) with + | Some idx -> arr[idx..] + | None -> [||] + +/// Takes elements from the start of an array while the predicate is true. +let arrayTakeWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = + match arr |> Array.tryFindIndex (predicate >> not) with + | Some idx -> arr[.. idx - 1] + | None -> arr + +/// Patterns for matching Python symbol definitions. +let symbolPatterns (symbol: string) = [ + symbol + " =" + symbol + ": " + "def " + symbol + "(" + "def " + symbol + "[" + "class " + symbol + "(" + "class " + symbol + ":" + "class " + symbol + "[" +] + +/// Checks if a line matches any of the symbol definition patterns. +let matchesSymbol (symbol: string) (line: string) : bool = + let trimmed = line.TrimStart() + symbolPatterns symbol |> List.exists trimmed.StartsWith + +/// Finds the definition index for a symbol in the source lines. +let findDefinitionIndex (symbol: string) (lines: string array) : int option = + lines |> Array.tryFindIndex (matchesSymbol symbol) + +/// Walks backwards from defIndex to find where decorators start. +let findDecoratorStart (lines: string array) (defIndex: int) : int = + Seq.init defIndex (fun i -> defIndex - 1 - i) + |> Seq.tryFind (fun i -> not (isDecorator lines[i])) + |> Option.map ((+) 1) + |> Option.defaultValue 0 + +/// Checks if a line starts a multiline definition. +let isMultilineDefinition (line: string) : bool = + let trimmed = line.TrimStart() + + trimmed.StartsWith "class " + || trimmed.StartsWith "def " + || trimmed.EndsWith "(" + || trimmed.EndsWith "[" + || trimmed.EndsWith "{" + +/// Extracts the body of a multiline definition. +let extractMultilineBody (startIndex: int) (defIndex: int) (lines: string array) : string = + let shouldStop idx (line: string) = + idx > defIndex && (isTopLevelDefinition line || isDunderMethod line) + + // Start from decorator or definition line + lines[startIndex..] + // Track position for stop condition + |> Array.indexed + // Take until next top-level definition + |> arrayTakeWhile (fun (i, line) -> not (shouldStop (startIndex + i) line)) + // Drop indices, keep lines + |> Array.map snd + // Reverse to trim from end + |> Array.rev + // Remove trailing blank lines + |> arraySkipWhile String.IsNullOrWhiteSpace + // Restore original order + |> Array.rev + // Join into final string + |> String.concat "\n" + +/// Extracts a single symbol definition from Python source lines. +let extractSymbol (symbol: string) (lines: string array) : string option = + findDefinitionIndex symbol lines + |> Option.map (fun defIndex -> + let startIndex = findDecoratorStart lines defIndex + + if isMultilineDefinition lines[defIndex] then + extractMultilineBody startIndex defIndex lines + else + lines[defIndex]) + +/// Extracts multiple symbols and combines them. +let extractSymbols (toPythonNaming: string -> string) (symbols: string list) (pythonContent: string) : string = + let lines = pythonContent.Split('\n') + + symbols + |> List.choose (fun sym -> extractSymbol (toPythonNaming sym) lines) + |> String.concat "\n\n" diff --git a/chapters/compatibility.fs b/chapters/compatibility.fs index e158475..1a2bb69 100644 --- a/chapters/compatibility.fs +++ b/chapters/compatibility.fs @@ -210,16 +210,23 @@ let mapOps = Map.ofList [ ("a", 1); ("b", 2) ] ### Options Are Erased -Options are optimized away at runtime: +Options are erased at runtime, which is actually a feature rather than a limitation. +This makes interop with Python libraries seamless - you can pass F# option values +directly to Python functions expecting `T | None`: *) let someValue = Some 42 // Compiles to just: 42 let noneValue = None // Compiles to: None (** -Note that Fable.Python uses a `SomeWrapper` class to handle nested options correctly. -`Some None` compiles to `SomeWrapper(None)`, which is distinct from plain `None`. -This means `Some (Some x)`, `Some None`, and `None` are all properly distinguishable. +This erasure means Python code receives native values without any wrapper overhead. +When calling a Python library that returns `Optional[T]`, you get values that work +directly with F# pattern matching. + +For the rare edge case of nested options (`Option>`), Fable.Python uses +a `SomeWrapper` to distinguish `Some None` from `None`. However, nested options +are uncommon in practice - the F# compiler warns about them in type annotations, +and well-designed library bindings avoid exposing them at API boundaries. ### Multi-line Lambdas diff --git a/chapters/interop.fs b/chapters/interop.fs index 2eefe53..adad3dd 100644 --- a/chapters/interop.fs +++ b/chapters/interop.fs @@ -206,7 +206,8 @@ For more complex Python code with statements: let factorial (count: int) : int = emitPyStatement - count """if $0 < 2: + count + """if $0 < 2: return 1 else: return $0 * factorial($0 - 1) @@ -222,7 +223,7 @@ let factorial (count: int) : int = type Direction = | North | South - | [] East // Custom string value + | [] East // Custom string value | West // North compiles to "north", East compiles to "E" @@ -235,13 +236,13 @@ Control the string format with `CaseRules`: [] type UserStatus = - | ActiveUser // -> "active_user" - | InactiveUser // -> "inactive_user" + | ActiveUser // -> "active_user" + | InactiveUser // -> "inactive_user" [] type CssBoxSizing = - | ContentBox // -> "content-box" - | BorderBox // -> "border-box" + | ContentBox // -> "content-box" + | BorderBox // -> "border-box" (** Available case rules: `None`, `LowerFirst`, `SnakeCase`, `SnakeCaseAllCaps`, `KebabCase`, `LowerAll`. @@ -255,6 +256,7 @@ Erased unions let you create type-safe wrappers that disappear at runtime: type StringOrInt = | AsString of string | AsInt of int + member this.Describe() = match this with | AsString s -> $"String: {s}" @@ -277,6 +279,7 @@ You can create custom decorators that wrap functions at compile time: type LogAttribute(msg: string) = inherit Py.DecoratorAttribute() + override _.Decorate(fn) = Py.argsFunc (fun args -> printfn $"LOG: {msg}" @@ -354,6 +357,7 @@ Bind to Python global objects with the `Global` attribute: type PyList = [] abstract append: item: obj -> unit + [] abstract length: int diff --git a/chapters/introduction.fs b/chapters/introduction.fs index 92181d5..8f68b54 100644 --- a/chapters/introduction.fs +++ b/chapters/introduction.fs @@ -27,8 +27,9 @@ With Fable.Python, you get all these benefits while targeting the Python ecosyst Fable.Python is a great choice when: -- **Python ecosystem access** - You need AI/ML libraries (PyTorch, TensorFlow, LangChain), - data science tools (Pandas, NumPy), or frameworks like Pydantic and FastAPI +- **Python ecosystem access** - You need AI/ML libraries (PyTorch, TensorFlow, + LangChain), data science tools (Pandas, NumPy), or frameworks like Pydantic and + FastAPI - **F# type safety** - You want pattern matching and exhaustive checking while using Python libraries - **Shared domain logic** - Write once in F#, run on .NET, JavaScript, Rust, and Python diff --git a/docs/blogpost.md b/docs/blogpost.md index fd05bdc..0b7e0a7 100644 --- a/docs/blogpost.md +++ b/docs/blogpost.md @@ -36,14 +36,14 @@ Fable.Python is a great choice when: ## When NOT to Use Fable.Python - When your F# code depends on .NET libraries without Fable support -- Performance-critical code (Python is still slow) +- Performance-critical code (Python has runtime overhead) - Team won't learn F# **Best fit:** You love F#, but need Python's ecosystem. -## A Simple Example +## A First Example -Let's start with something simple. Here's F# code that will compile to Python: +Let's start with F# code that compiles to Python: ```fsharp let greet name = $"Hello, {name}!" @@ -51,15 +51,22 @@ let greet name = $"Hello, {name}!" let message = greet "Fable.Python" ``` -When compiled with Fable, this generates clean, readable Python: +When compiled with Fable, this generates the following Python: ```python -def greet(name): - return f"Hello, {name}!" +def greet[_A](name: Any | None = None) -> str: + return ("Hello, " + str(name)) + "!" -message = greet("Fable.Python") +message: str = greet("Fable.Python") ``` +The `name: Any | None = None` signature may look odd at first. This happens because +F# infers the type from usage - since we only call `greet` with a string, the compiler +doesn't know if it might also be called with unit `()` (no argument). If it were, +Python would call it as `greet()` instead of `greet("Fable.Python")`. Adding an +explicit type annotation `let greet (name: string) = ...` would generate a cleaner +`name: str` parameter. + ## The Power of Types F# shines when modeling domain concepts. Consider this example: @@ -79,9 +86,11 @@ let shapes = [ Circle 5.0; Rectangle(3.0, 4.0) ] let totalArea = shapes |> List.sumBy area ``` -This compiles to Python while preserving the semantic meaning. The discriminated -union becomes a tagged class structure, and pattern matching becomes clean -conditional logic. +This compiles to Python while preserving the semantic meaning. The `Shape` type +becomes a tagged class structure, and the `match` expression becomes clean +conditional logic. The compiler ensures you handle all cases - if you add a +new shape variant, the compiler will warn you about unhandled cases in +the `area` function. ## What's Next? @@ -91,13 +100,13 @@ In the following chapters, we'll cover: - **Bindings** - Working with Python libraries from F# - **Compatibility** - Understanding what F# features are supported -Let's dive in! +Let's begin. ## Are You a Python Developer? -If you're coming from Python, welcome! This chapter will help you understand -the F# code you'll see throughout this guide. Don't worry - F# is more -approachable than it might first appear, and many concepts will feel familiar. +If you're coming from Python, welcome. This chapter will help you understand +the F# code you'll see throughout this guide. F# is more approachable than +it might appear, and many concepts are familiar. ### What is F#? @@ -190,8 +199,8 @@ let area shape = | Rectangle(width, height) -> width * height ``` -The compiler will warn you if you forget to handle a case. No more runtime -`AttributeError` because you forgot a shape type! +The compiler warns you if you forget to handle a case. No more runtime +`AttributeError` because you forgot a shape type. #### Records @@ -291,7 +300,7 @@ unwrap the option first. | Dictionary | `{"a": 1}` | `Map.ofList [("a", 1)]` | | None check | `if x is None:` | `match x with None ->` | | String format | `f"Hello {name}"` | `$"Hello {name}"` | -| Type annotation | `x: int` | `x: int` (same!) | +| Type annotation | `x: int` | `x: int32`. | | Comments | `# comment` | `// comment` | | Multiline string | `"""text"""` | `"""text"""` (same!) | @@ -352,10 +361,10 @@ dotnet new console -lang F# # Set up local tools and install Fable 5 (alpha) dotnet new tool-manifest -dotnet tool install fable --version 5.0.0-alpha.17 +dotnet tool install fable --version 5.0.0-alpha.20 # Add Fable.Core package -dotnet add package Fable.Core --version 5.0.0-beta.2 +dotnet add package Fable.Core --version 5.0.0-beta.4 ``` ### Install Python Dependencies @@ -363,11 +372,15 @@ dotnet add package Fable.Core --version 5.0.0-beta.2 Fable-generated Python code requires the `fable-library` runtime: ```bash -pip install "fable-library==5.0.0a17" +pip install "fable-library==5.0.0a20" ``` -> **Note:** Version pinning is important! The fable-library version must match -> your Fable compiler version. PyPI uses `5.0.0a17` format instead of `5.0.0-alpha.17`. +--- + +**Note:** Version pinning matters. The fable-library version must match +your Fable compiler version. PyPI uses `5.0.0a20` format instead of `5.0.0-alpha.20`. + +--- ### Your First Program @@ -494,6 +507,9 @@ let length = builtins.len [ 1; 2; 3 ] let absValue = builtins.abs (-42) ``` +The `builtins` module provides typed access to Python's built-in functions. +These calls compile directly to `len([1, 2, 3])` and `abs(-42)` in Python. + #### Working with sys Module ```fsharp @@ -511,6 +527,9 @@ let fileName = os.path.basename "/path/to/file.txt" let dirName = os.path.dirname "/path/to/file.txt" ``` +The `os.path` functions work with arrays of path segments. These compile to +Python's `os.path.join`, `os.path.basename`, and `os.path.dirname` calls. + ### Environment Variables Use `os.getenv` to safely retrieve environment variables: @@ -728,12 +747,10 @@ type DecoratedUser() = This generates: ```python -from dataclasses import dataclass - @dataclass class DecoratedUser: + Age: int32 = int32.ZERO Name: str = "" - Age: int = 0 ``` ### Class Attributes and DataClasses @@ -753,8 +770,8 @@ This generates class-level type annotations suitable for Pydantic: ```python class PydanticModel: + Age: int32 = int32.ZERO Name: str = "" - Age: int = 0 ``` #### Py.DataClass Shorthand @@ -873,9 +890,9 @@ let json: IExports = nativeOnly This generates: `import json` -The `[]` attribute means the interface only exists at compile time - -no code is generated for it. The `nativeOnly` placeholder tells Fable the -value will be resolved at runtime. +The `[]` attribute means the interface only exists at compile time +(erased = no code generated for it). The `nativeOnly` placeholder tells Fable +the value will be resolved at runtime. ### Import Attributes @@ -946,7 +963,8 @@ let upper (s: string) : string = nativeOnly ### Function Overloads -**Prefer overloads over erased unions.** Instead of: +**Why prefer overloads over erased unions?** Erased unions like `U2` +require callers to wrap values explicitly, creating friction. Instead of: ```fsharp // ❌ Avoid this - creates friction for callers @@ -1116,14 +1134,14 @@ which F# features work with Fable.Python and any limitations to be aware of. ## F# Compatibility in Fable.Python -Understanding what works and what doesn't is crucial when targeting Python -with Fable. This chapter covers supported features, limitations, and -important differences from .NET. +This chapter covers supported features, limitations, and important differences +from .NET when targeting Python with Fable. ### Common Types and Objects -Some F#/.NET types have counterparts in Python. Fable takes advantage of this -to compile to native types that are more performant and reduce code size. +Some F#/.NET types have counterparts in Python. Fable takes advantage of +this to compile to native types that are more performant and reduce code +size. Native types also simplify interop with Python code and libraries. The most important common types are: | F#/.NET Type | Python Type | Notes | @@ -1134,7 +1152,7 @@ The most important common types are: | `Tuple` | `tuple` | Native Python tuple | | `ResizeArray` | `list` | Native Python list | | `Dictionary` | `dict` | Native Python dict | -| `seq` / `IEnumerable` | iterator | Uses `__iter__` protocol | +| `seq` / `IEnumerable` | `Iterable` | Uses `__iter__` protocol | | `Array` | `FSharpArray` | Custom wrapper for F# semantics | ### .NET Base Class Library @@ -1160,7 +1178,7 @@ Most FSharp.Core operators are supported, including formatting with `sprintf`, | F# Type | Python | | ----------------- | -------------------------- | | `Tuple` | `tuple` | -| `Option` | erased (see caveats) | +| `Option` | erased to `T \| None` | | `string` | `str` | | `List` | `List.fs` (immutable list) | | `Map` | `Map.fs` (immutable map) | @@ -1205,8 +1223,6 @@ let numbers = [ 1; 2; 3; 4; 5 ] let mutableList = ResizeArray() ``` -This generates: - ```python greeting: str = "Hello, Python!" @@ -1215,10 +1231,17 @@ is_enabled: bool = True coordinates: tuple[float64, float64] = (float64(10.5), float64(20.3)) numbers: FSharpList[int32] = of_array( + Array[int32]([int32.ONE, int32.TWO, int32.THREE, int32.FOUR, int32.FIVE]) +) mutable_list: list[int32] = [] ``` +Each of these F# values compiles to its Python equivalent. Strings become `str`, +booleans become `bool`, and tuples become Python tuples. The F# `list` uses the +fable-library implementation for immutable semantics, while `ResizeArray` +compiles directly to Python's mutable `list`. + #### Functions and Lambdas First-class functions work as expected: @@ -1231,6 +1254,10 @@ let applyTwice f x = f (f x) let result = applyTwice (add 1) 5 // 7 ``` +Functions are first-class values in F#. The `applyTwice` function takes another +function `f` as a parameter and applies it twice. Partial application works +naturally - `(add 1)` creates a new function that adds 1 to its argument. + #### Pattern Matching Full pattern matching support: @@ -1270,120 +1297,7 @@ let person = { } ``` -This generates: - ```python -from abc import abstractmethod -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, Protocol -from fable_library.array_ import map as map_1 -from fable_library.array_ import Array, Int32Array -from fable_library.list import of_array, FSharpList, sum, map, filter -from fable_library.map import of_list as of_list_1 -from fable_library.range import range_big_int -from fable_library.reflection import ( - TypeInfo, - union_type, - string_type, - int32_type, - option_type, - record_type, - float64_type, - class_type, -) -from fable_library.seq import to_list -from fable_library.set import of_list -from fable_library.types import float64, int32, Union, Record -from fable_library.util import int32 as int32_1, compare_primitives - -greeting: str = "Hello, Python!" - -is_enabled: bool = True - -coordinates: tuple[float64, float64] = (float64(10.5), float64(20.3)) - -numbers: FSharpList[int32] = of_array( - Array[int32]([int32.ONE, int32.TWO, int32.THREE, int32.FOUR, int32.FIVE]) -) - -mutable_list: list[int32] = [] - - -def add(x: int32, y: int32) -> int32: - return x + y - - -def multiply(x: int32, y: int32) -> int32: - return x * y - - -def apply_twice[_A](f: Callable[[_A], _A], x: _A) -> _A: - return f(f(x)) - - -def _arrow27(y: int32) -> int32: - return add(int32.ONE, y) - - -result: int32 = apply_twice(_arrow27, int32.FIVE) - - -def _expr28(gen0: TypeInfo, gen1: TypeInfo) -> TypeInfo: - return union_type( - "Compatibility.Result`2", - [gen0, gen1], - Result_2, - lambda: [[("Item", gen0)], [("Item", gen1)]], - ) - - -class Result_2[E, T](Union): - def __init__(self, tag: int32, *fields: Any) -> None: - super().__init__() - self.tag: int32 = tag - self.fields: Array[Any] = Array[Any](fields) - - @staticmethod - def cases() -> list[str]: - return ["Ok", "Error"] - - -Result_2_reflection = _expr28 - - -def handle_result[_A, _B](result_1: Result_2[Any, Any]) -> str: - if result_1.tag == int32_1(1): - return ("Failed: " + str(result_1.fields[int32_1(0)])) + "" - - else: - return ("Success: " + str(result_1.fields[int32_1(0)])) + "" - - -def active_pattern_example(input: int32) -> str: - if input > int32.ZERO: - return "positive" - - elif input < int32.ZERO: - return "negative" - - else: - return "zero" - - -def _expr29() -> TypeInfo: - return record_type( - "Compatibility.Person", - [], - Person, - lambda: [ - ("name", string_type), - ("age", int32_type), - ("email", option_type(string_type)), - ], - ) - - @dataclass(eq=False, repr=False, slots=True) class Person(Record): name: str @@ -1473,6 +1387,17 @@ let processed = // Becomes a separate function in Python ``` +We can see that the mapping becomes a separate function in the generated Python code. + +```python +def mapping(x_1: int32) -> int32: + return x_1 * x_1 + +processed: FSharpList[int32] = map( + mapping, of_array(Array[int32]([int32.ONE, int32.TWO, int32.THREE])) +) +``` + #### Numeric Types Numeric types in Fable.Python are implemented using custom PyO3 wrapper types @@ -1508,6 +1433,18 @@ let wrapped: int = maxInt + 1 // Wraps around like .NET let huge: bigint = 999999999999999999999999999999I ``` +This generates: + +```python +small: int32 = int32(42) + +big: int = 12345678901234567890 + +wrapped: int32 = max_int + int32.ONE + +huge: int = 999999999999999999999999999999 +``` + #### Computation Expressions Async and task computation expressions have some differences from .NET. @@ -1525,7 +1462,8 @@ If your project has `[]`, you need: ``` -This ensures absolute imports in generated Python. +This ensures the use of absolute imports in generated Python. Applications +in Python must use absolute imports to run correctly. #### Libraries @@ -1543,7 +1481,7 @@ Libraries use relative imports by default, which is correct for packages. Fable.Python provides excellent F# support. The main things to watch for are: - Option erasure in edge cases -- Multi-line lambda lifting +- Multi-line lambda lifting, will not be anonymous - Some .NET APIs may be missing For most F# code, you can write idiomatic functional code and it will @@ -1580,8 +1518,7 @@ The Python target has received special attention in v5: ### Rust Core with PyO3 One of the biggest changes is that the core of fable-library is now written -in **Rust** using PyO3. This isn't primarily for performance - it's for -**correctness**: +in **Rust** using PyO3. The motivation is **correctness**, not performance: #### Why Rust? @@ -1638,7 +1575,7 @@ Then compile your F# to Python: dotnet fable YourProject.fsproj --lang python -o output/ ``` -The generated Python code will be modern, type-hinted, and ready to run! +The generated Python code will be modern, type-hinted, and ready to run. ## Pydantic Interop @@ -1805,7 +1742,7 @@ let speed = distance / time // Automatically inferred as float ``` The compiler tracks units through all operations. Division of meters by -seconds gives meters-per-second. This is all checked at compile time! +seconds gives meters-per-second. This is all checked at compile time. ### Preventing Errors @@ -1815,7 +1752,7 @@ Try to add incompatible units and the compiler stops you: let distance = 100.0 let mass = 50.0 -// This won't compile! +// This won't compile: // let nonsense = distance + mass // Error: The unit of measure 'm' does not match 'kg' ``` @@ -1899,13 +1836,13 @@ unit checking. With Fable.Python, you can: This is especially valuable for physics simulations, financial calculations, engineering applications, and any domain where mixing up units could be costly. -## Fabletext: The Strange Loop +## Fable.Literate: The Strange Loop You've made it to the end - and here's where things get delightfully meta. **The blog post you're reading was generated by the code in this chapter.** -This is Fabletext, a literate programming converter inspired by +This is Fable.Literate, a literate programming converter inspired by [jupytext](https://github.com/mwouts/jupytext) and [FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/). It's written in F#, compiled to Python via Fable, and it processes the @@ -1915,7 +1852,7 @@ The chain goes like this: 1. Each chapter is an F# file with embedded Markdown comments 2. Fable compiles the F# to Python -3. Fabletext (this code, running as Python) extracts the documentation +3. Fable.Literate (this code, running as Python) extracts the documentation 4. The output is the Markdown you're reading right now It's a strange loop - the snake eating its tail. And it proves that @@ -1923,314 +1860,95 @@ Fable.Python isn't just a toy: you're looking at a real project that works. ### How It Works -The converter is a simple state machine that processes input line by line: +The converter follows a compiler-like architecture with three phases: -- Lines inside `(** ... *)` blocks are emitted as Markdown -- F# code outside those blocks is wrapped in fenced code blocks -- `(*** hide ***)` sections are excluded from output - -### Parser State +1. **Parse**: Convert source lines into a Block AST +2. **Transform**: Filter hidden blocks, resolve Python includes +3. **Print**: Render the AST as Markdown -We track three possible states as we scan through the file: +The input syntax: -```fsharp -/// Represents the current state of the parser state machine. -type ParserState = - /// Inside a markdown block (** ... *) - | InMarkdown - /// Regular F# code outside markdown blocks - | InCode - /// Hidden section after (*** hide ***), content is skipped - | Hidden -``` +- Lines inside `(** ... *)` blocks become Markdown +- F# code outside those blocks is wrapped in fenced code blocks +- `(*** hide ***)` sections are excluded from output +- `(*** include-python: symbol1, symbol2 ***)` extracts generated Python code -### Line Classification +### AST Types -Each line is classified using an active pattern to determine how to handle it. -The pattern also extracts content from markdown start lines: +The document is represented as a list of blocks. Each block represents +a distinct section of the literate source file: ```fsharp -/// Parses a comma-separated list of symbols from an include-python directive. -let parseSymbolList (directive: string) : string list = - // Extract content between "(*** include-python:" and "***)" - let start = "(*** include-python:".Length - let endPos = directive.LastIndexOf("***)") - if endPos > start then - directive.Substring(start, endPos - start).Trim() - |> fun s -> s.Split(',') - |> Array.map (fun s -> s.Trim()) - |> Array.filter (fun s -> s.Length > 0) - |> Array.toList - else - [] - -/// Active pattern for classifying source lines. -/// - `HideCmd`: The (*** hide ***) directive -/// - `IncludePythonCmd symbols`: The (*** include-python: sym1, sym2 ***) directive -/// - `MarkdownSingle content`: Single-line markdown (** content *) -/// - `MarkdownOpen content`: Start of markdown block, possibly with content -/// - `MarkdownClose`: End of markdown block *) -/// - `Content`: Any other line -let (|HideCmd|IncludePythonCmd|MarkdownSingle|MarkdownOpen|MarkdownClose|Content|) (line: string) = - let trimmed = line.Trim() - - match trimmed with - | "(*** hide ***)" -> HideCmd - | s when s.StartsWith("(*** include-python:") && s.EndsWith("***)") -> - IncludePythonCmd(parseSymbolList s) - | s when s.StartsWith("(**") && s.EndsWith("*)") && s.Length > 5 -> - MarkdownSingle(s.Substring(3, s.Length - 5).Trim()) - | s when s.StartsWith("(**") -> - let content = if s.Length > 3 then s.Substring(3).Trim() else "" - MarkdownOpen content - | "*)" -> MarkdownClose - | _ -> Content -``` - -### State Transitions - -The heart of the parser - handling transitions between states: - -```fsharp -/// The parsing context that tracks state, buffered code, and output. -type ParseContext = { - /// Current parser state - State: ParserState - /// Accumulated code lines waiting to be flushed - CodeBuffer: string list - /// Accumulated output chunks (in reverse order) - Output: string list -} +/// A single block in the document AST. +type Block = + /// Raw markdown content from (** ... *) blocks + | Markdown of content: string + /// F# code that should be wrapped in fenced blocks + | FSharpCode of lines: string list + /// Hidden content - filtered out by Transform.filterHidden + | Hidden of lines: string list + /// Unresolved directive to include Python symbols (from parsing) + /// Resolved to PythonCode by Transform.resolvePythonIncludes + | IncludePython of symbols: string list + /// Resolved Python code (after Transform.resolvePythonIncludes) + | PythonCode of content: string -/// Initial empty parsing context. -let emptyContext = { - State = InCode - CodeBuffer = [] - Output = [] -} - -/// Active pattern that matches strings starting with any of the given prefixes. -let (|StartsWithAny|_|) (prefixes: string list) (s: string) = - let trimmed = s.Trim() - - if prefixes |> List.exists trimmed.StartsWith then - Some() - else - None - -/// Boilerplate prefixes that should be excluded from code blocks. -let boilerplatePrefixes = [ "module "; "namespace " ] - -/// Flushes the code buffer to output as a fenced code block. -/// Skips empty or boilerplate-only code blocks. -let flushCodeBuffer (ctx: ParseContext) : ParseContext = - if ctx.CodeBuffer.IsEmpty then - ctx - else - let code = - ctx.CodeBuffer - |> List.rev - |> String.concat "\n" - |> fun s -> s.Trim() // Remove leading/trailing empty lines - // Skip empty, whitespace-only, or boilerplate code blocks - match code with - | s when String.IsNullOrWhiteSpace s -> { ctx with CodeBuffer = [] } - | StartsWithAny boilerplatePrefixes -> { ctx with CodeBuffer = [] } - | _ -> - // Add blank line before and after code block for markdown lint compliance - let block = $"\n```fsharp\n{code}\n```\n\n" - - { - ctx with - CodeBuffer = [] - Output = block :: ctx.Output - } - -/// Mutable storage for Python file content (set via CLI argument). -let mutable pythonFileContent: string option = None - -/// Checks if a line starts a new top-level definition (not indented). -let isTopLevelDefinition (line: string) : bool = - not (String.IsNullOrWhiteSpace line) - && not (line.StartsWith " ") - && not (line.StartsWith "\t") - && not (line.StartsWith "#") - -/// Checks if a line is a decorator. -let isDecorator (line: string) : bool = - line.TrimStart().StartsWith "@" - -/// Checks if a line is a dunder method definition. -let isDunderMethod (line: string) : bool = - let trimmed = line.TrimStart() - trimmed.StartsWith "def __" - -/// Skips elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.skipWhile. -let arraySkipWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[idx..] - | None -> [||] - -/// Takes elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.takeWhile. -let arrayTakeWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[..idx - 1] - | None -> arr - -/// Extracts a single symbol definition from Python source lines. -/// Returns the definition including any decorators, stopping before dunder methods. -let extractSymbol (symbol: string) (lines: string array) : string option = - let symbolPatterns = [ - $"{symbol} ="; $"{symbol}: "; $"def {symbol}(" - $"class {symbol}("; $"class {symbol}:" - ] - - let matchesSymbol (line: string) = - let trimmed = line.TrimStart() - symbolPatterns |> List.exists trimmed.StartsWith - - lines - |> Array.tryFindIndex matchesSymbol - |> Option.map (fun defIndex -> - // Walk backwards to include decorators - let startIndex = - Seq.init defIndex (fun i -> defIndex - 1 - i) - |> Seq.tryFindBack (fun i -> not (isDecorator lines[i])) - |> Option.map ((+) 1) - |> Option.defaultValue 0 - - let defLine = lines[defIndex].TrimStart() - let isMultiline = defLine.StartsWith "class " || defLine.StartsWith "def " - - if not isMultiline then - lines[defIndex] - else - // Take lines until we hit a new top-level def or dunder method - let shouldStop idx (line: string) = - idx > defIndex && (isTopLevelDefinition line || isDunderMethod line) - - lines[startIndex..] - |> Array.indexed - |> arrayTakeWhile (fun (i, line) -> not (shouldStop (startIndex + i) line)) - |> Array.map snd - |> Array.rev - |> arraySkipWhile String.IsNullOrWhiteSpace - |> Array.rev - |> String.concat "\n" - ) - -/// Extracts multiple symbols and combines them. -let extractSymbols (symbols: string list) (pythonContent: string) : string = - let lines = pythonContent.Split('\n') - symbols - |> List.choose (fun sym -> extractSymbol sym lines) - |> String.concat "\n\n" - -/// Processes a single line, updating the parse context based on state transitions. -let processLine (ctx: ParseContext) (line: string) : ParseContext = - match ctx.State, line with - // Entering hidden mode - | _, HideCmd -> { flushCodeBuffer ctx with State = Hidden } - - // Include Python symbols from transpiled output - | (InCode | Hidden), IncludePythonCmd symbols -> - let flushed = flushCodeBuffer ctx - match pythonFileContent with - | Some pythonContent -> - let extracted = extractSymbols symbols pythonContent - if extracted.Length > 0 then - let block = $"\nThis generates:\n\n```python\n{extracted}\n```\n\n" - { flushed with Output = block :: flushed.Output } - else - flushed // No symbols found, emit nothing - | None -> - // No Python file provided, emit a placeholder comment - let symbolList = String.concat ", " symbols - let placeholder = $"\n\n" - { flushed with Output = placeholder :: flushed.Output } - - // Single-line markdown: (** content *) - | (InCode | Hidden), MarkdownSingle content -> - let flushed = flushCodeBuffer ctx +/// A parsed document is a list of blocks. +type Document = Block list +``` - { flushed with Output = (content + "\n") :: flushed.Output } +### Utils Module - // Starting markdown block with or without content - | (InCode | Hidden), MarkdownOpen content -> - let flushed = flushCodeBuffer ctx +Utility functions for naming conversion and line classification: - if content.Length > 0 then - { - flushed with - State = InMarkdown - Output = (content + "\n") :: flushed.Output - } - else - { flushed with State = InMarkdown } +### Parser Module - // Ending markdown block - | InMarkdown, MarkdownClose -> { ctx with State = InCode } +The parser converts source lines into a Block AST using a fold: - // Content inside markdown - | InMarkdown, Content -> { ctx with Output = (line + "\n") :: ctx.Output } +### Transform Module - // Code line (not hidden) - | InCode, Content -> { ctx with CodeBuffer = line :: ctx.CodeBuffer } +Pure transformations on the document AST: - // Hidden content - skip - | Hidden, (Content | MarkdownClose) -> ctx +### MarkdownPrinter Module - // Ignore markdown markers in wrong state - | InMarkdown, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx - | InCode, MarkdownClose -> ctx -``` +Renders the document AST to markdown: -### Processing a File +### Pipeline Module -Read all lines, process them, and return the Markdown output: +Composes the phases into a complete pipeline: -```fsharp -/// Processes all lines from a literate F# file and returns the Markdown output. -let processLines (lines: string seq) : string = - let finalCtx = lines |> Seq.fold processLine emptyContext |> flushCodeBuffer // Flush any remaining code - finalCtx.Output |> List.rev |> String.concat "" -``` +### Including Generated Python Code -### Header Level Adjustment +One of Fable.Literate's unique features is the ability to show the generated Python +alongside the F# source. The include-python directive extracts specific symbols +from the transpiled output. -For concatenating multiple chapters into a single document, we need to -increase header levels (# becomes ##, ## becomes ###, etc.): +When you pass `--python-file path` to Fable.Literate, it reads the transpiled +Python and extracts the named symbols (functions, classes, or variables). +This lets readers see exactly what Python code Fable generates from the F#. -```fsharp -/// Increases all markdown header levels by one (# becomes ##, etc.). -/// Preserves headers inside fenced code blocks. -let adjustHeaderLevels (markdown: string) : string = - let lines = markdown.Split('\n') +The extraction is smart about Python syntax: - let folder (inCodeBlock, acc) (line: string) = - match line with - | s when s.StartsWith("```") -> not inCodeBlock, line :: acc - | _ when inCodeBlock -> inCodeBlock, line :: acc - | s when s.StartsWith("#") -> inCodeBlock, ("#" + line) :: acc - | _ -> inCodeBlock, line :: acc +- It finds the symbol definition by matching patterns like def symbol or class symbol +- It walks backwards to include any decorators +- For multi-line definitions, it captures everything until the next top-level definition +- It stops before dunder methods to avoid pulling in too much - lines |> Array.fold folder (false, []) |> snd |> List.rev |> String.concat "\n" -``` +For example, the extractSymbol function in F# generates this Python: -### Python File I/O +```python +def extract_symbol(symbol: str, lines: Array[str]) -> str | None: + """Extracts a single symbol definition from Python source lines.""" -For Fable.Python, we use Python's file operations: + def mapping(def_index: int32, symbol: Any = symbol, lines: Any = lines) -> str: + start_index: int32 = find_decorator_start(lines, def_index) + if is_multiline_definition(lines[def_index]): + return extract_multiline_body(start_index, def_index, lines) -```fsharp -/// Reads the entire contents of a file as a string. -[] -let readFile (path: string) : string = nativeOnly + else: + return lines[def_index] -/// Prints a string to stdout without a trailing newline. -[] -let printRaw (s: string) : unit = nativeOnly + return map(mapping, find_definition_index(symbol, lines)) ``` ### Main Entry Point @@ -2245,6 +1963,16 @@ let getFlagValue (flag: string) (args: string[]) : string option = |> Option.bind (fun i -> if i + 1 < args.Length then Some args.[i + 1] else None) +/// Extracts positional arguments (file paths) from command line args. +/// Filters out flags (--foo) and their values (--python-file path.py). +let getPositionalArgs (args: string[]) : string[] = + let isFlag (arg: string) = arg.StartsWith "--" + let isValueOfFlag i = i > 0 && args.[i - 1] = "--python-file" + args + |> Array.indexed + |> Array.filter (fun (i, arg) -> not (isFlag arg) && not (isValueOfFlag i)) + |> Array.map snd + /// Main entry point. Converts a literate F# file to Markdown. /// Use --increase-headers flag to bump all header levels by one. /// Use --python-file to enable include-python directives. @@ -2252,31 +1980,27 @@ let getFlagValue (flag: string) (args: string[]) : string option = let main (args: string[]) = let hasFlag flag = args |> Array.contains flag let pythonFilePath = getFlagValue "--python-file" args - // Filter out flags and their values - let files = - args - |> Array.indexed - |> Array.filter (fun (i, a) -> - not (a.StartsWith "--") - && not (i > 0 && args.[i - 1] = "--python-file")) - |> Array.map snd + let files = getPositionalArgs args if files.Length < 1 then - printfn "Usage: python fabletext.py [--increase-headers] [--python-file ] " + printfn "Usage: python app.py [--increase-headers] [--python-file ] " 1 else + // Thanks to the contributor! (Fable-style) + eprintln $"Fable.Literate: Thanks to the contributor! {randomContributor ()}" + // Load Python file content if provided - pythonFileContent <- - pythonFilePath - |> Option.map readFile + let pythonContent = pythonFilePath |> Option.map readFile let content = readFile files.[0] let lines = content.Split('\n') - let markdown = processLines lines + + // Pipeline: parse -> transform -> print + let markdown = lines |> Pipeline.standard pythonContent let output = if hasFlag "--increase-headers" then - adjustHeaderLevels markdown + MarkdownPrinter.adjustHeaderLevels markdown else markdown @@ -2288,10 +2012,10 @@ let main (args: string[]) = ```bash # Transpile to Python -dotnet fable tools/ --lang python -o output/tools/ +dotnet fable Fable.Literate/ --lang python -o output/Fable.Literate/ # Convert a literate file -python output/tools/fabletext.py chapters/introduction.fs > docs/introduction.md +python output/Fable.Literate/app.py chapters/introduction.fs > docs/introduction.md ``` That's it! A complete literate programming converter in under 200 lines of F#. diff --git a/docs/compatibility.md b/docs/compatibility.md index eed2c20..4f10116 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -253,6 +253,8 @@ let processed = // Becomes a separate function in Python ``` +We can see that the mapping becomes a separate function in the generated Python code. + ```python def mapping(x_1: int32) -> int32: return x_1 * x_1 @@ -297,6 +299,18 @@ let wrapped: int = maxInt + 1 // Wraps around like .NET let huge: bigint = 999999999999999999999999999999I ``` +This generates: + +```python +small: int32 = int32(42) + +big: int = 12345678901234567890 + +wrapped: int32 = max_int + int32.ONE + +huge: int = 999999999999999999999999999999 +``` + ### Computation Expressions Async and task computation expressions have some differences from .NET. @@ -314,7 +328,8 @@ If your project has `[]`, you need: ``` -This ensures absolute imports in generated Python. +This ensures the use of absolute imports in generated Python. Applications +in Python must use absolute imports to run correctly. ### Libraries @@ -332,7 +347,7 @@ Libraries use relative imports by default, which is correct for packages. Fable.Python provides excellent F# support. The main things to watch for are: - Option erasure in edge cases -- Multi-line lambda lifting +- Multi-line lambda lifting, will not be anonymous - Some .NET APIs may be missing For most F# code, you can write idiomatic functional code and it will diff --git a/docs/fable-literate.md b/docs/fable-literate.md new file mode 100644 index 0000000..38637f6 --- /dev/null +++ b/docs/fable-literate.md @@ -0,0 +1,197 @@ +# Fable.Literate: The Strange Loop + +You've made it to the end - and here's where things get delightfully meta. + +**The blog post you're reading was generated by the code in this chapter.** + +This is Fable.Literate, a literate programming converter inspired by +[jupytext](https://github.com/mwouts/jupytext) and +[FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/). +It's written in F#, compiled to Python via Fable, and it processes the +`.fs` files that make up this blog - including itself. + +The chain goes like this: + +1. Each chapter is an F# file with embedded Markdown comments +2. Fable compiles the F# to Python +3. Fable.Literate (this code, running as Python) extracts the documentation +4. The output is the Markdown you're reading right now + +It's a strange loop - the snake eating its tail. And it proves that +Fable.Python isn't just a toy: you're looking at a real project that works. + +## How It Works + +The converter follows a compiler-like architecture with three phases: + +1. **Parse**: Convert source lines into a Block AST +2. **Transform**: Filter hidden blocks, resolve Python includes +3. **Print**: Render the AST as Markdown + +The input syntax: + +- Lines inside `(** ... *)` blocks become Markdown +- F# code outside those blocks is wrapped in fenced code blocks +- `(*** hide ***)` sections are excluded from output +- `(*** include-python: symbol1, symbol2 ***)` extracts generated Python code + +## AST Types + +The document is represented as a list of blocks. Each block represents +a distinct section of the literate source file: + +```fsharp +/// A single block in the document AST. +type Block = + /// Raw markdown content from (** ... *) blocks + | Markdown of content: string + /// F# code that should be wrapped in fenced blocks + | FSharpCode of lines: string list + /// Hidden content - filtered out by Transform.filterHidden + | Hidden of lines: string list + /// Unresolved directive to include Python symbols (from parsing) + /// Resolved to PythonCode by Transform.resolvePythonIncludes + | IncludePython of symbols: string list + /// Resolved Python code (after Transform.resolvePythonIncludes) + | PythonCode of content: string + +/// A parsed document is a list of blocks. +type Document = Block list +``` + +## Utils Module + +Utility functions for naming conversion and line classification: + +## Parser Module + +The parser converts source lines into a Block AST using a fold: + +## PythonExtract Module + +Extracts symbol definitions from transpiled Python source code: + +## Transform Module + +Pure transformations on the document AST: + +## MarkdownPrinter Module + +Renders the document AST to markdown: + +## Pipeline Module + +Composes the phases into a complete pipeline: + +## Including Generated Python Code + +One of Fable.Literate's unique features is the ability to show the generated Python +alongside the F# source. The include-python directive extracts specific symbols +from the transpiled output. + +When you pass `--python-file path` to Fable.Literate, it reads the transpiled +Python and extracts the named symbols (functions, classes, or variables). +This lets readers see exactly what Python code Fable generates from the F#. + +The extraction is smart about Python syntax: + +- It finds the symbol definition by matching patterns like def symbol or class symbol +- It walks backwards to include any decorators +- For multi-line definitions, it captures everything until the next top-level definition +- It stops before dunder methods to avoid pulling in too much + +For example, the extractSymbol function in F# generates this Python: + +```python +def PythonExtract_extractSymbol(symbol: str, lines: Array[str]) -> str | None: + """Extracts a single symbol definition from Python source lines.""" + + def mapping(def_index: int32, symbol: Any = symbol, lines: Any = lines) -> str: + start_index: int32 = PythonExtract_findDecoratorStart(lines, def_index) + if PythonExtract_isMultilineDefinition(lines[def_index]): + return PythonExtract_extractMultilineBody(start_index, def_index, lines) + + else: + return lines[def_index] + + return map_1(mapping, PythonExtract_findDefinitionIndex(symbol, lines)) +``` + +## Main Entry Point + +Read the input file, convert it, and print the result: + +```fsharp +/// Gets the value following a flag argument (e.g., --python-file path.py). +let getFlagValue (flag: string) (args: string[]) : string option = + args + |> Array.tryFindIndex ((=) flag) + |> Option.bind (fun i -> + if i + 1 < args.Length then Some args.[i + 1] else None) + +/// Extracts positional arguments (file paths) from command line args. +/// Filters out flags (--foo) and their values (--python-file path.py). +let getPositionalArgs (args: string[]) : string[] = + let isFlag (arg: string) = arg.StartsWith "--" + let isValueOfFlag i = i > 0 && args.[i - 1] = "--python-file" + args + |> Array.indexed + |> Array.filter (fun (i, arg) -> not (isFlag arg) && not (isValueOfFlag i)) + |> Array.map snd + +/// Main entry point. Converts a literate F# file to Markdown. +/// Use --increase-headers flag to bump all header levels by one. +/// Use --python-file to enable include-python directives. +[] +let main (args: string[]) = + let hasFlag flag = args |> Array.contains flag + let pythonFilePath = getFlagValue "--python-file" args + let files = getPositionalArgs args + + if files.Length < 1 then + printfn "Usage: python app.py [--increase-headers] [--python-file ] " + 1 + else + // Thanks to the contributor! (Fable-style) + eprintln $"Fable.Literate: Thanks to the contributor! {randomContributor ()}" + + // Load Python file content if provided + let pythonContent = pythonFilePath |> Option.map readFile + + let content = readFile files.[0] + let lines = content.Split('\n') + + // Pipeline: parse -> transform -> print + let markdown = lines |> Pipeline.standard pythonContent + + let output = + if hasFlag "--increase-headers" then + MarkdownPrinter.adjustHeaderLevels markdown + else + markdown + + printRaw output + 0 +``` + +## Building and Running + +```bash +# Transpile to Python +dotnet fable Fable.Literate/ --lang python -o output/Fable.Literate/ + +# Convert a literate file +python output/Fable.Literate/app.py chapters/introduction.fs > docs/introduction.md +``` + +That's it! A complete literate programming converter in under 200 lines of F#. + +## The Punchline + +If you're reading this, the code worked. + +This entire blog post - every chapter, every code example, every explanation - +was processed by the F# code you just read, compiled to Python, and output +as Markdown. The proof is in the reading. + +Welcome to Fable.Python. Now go build something. diff --git a/docs/fabletext.md b/docs/fabletext.md deleted file mode 100644 index c0445b7..0000000 --- a/docs/fabletext.md +++ /dev/null @@ -1,563 +0,0 @@ -# Fabletext: The Strange Loop - -You've made it to the end - and here's where things get delightfully meta. - -**The blog post you're reading was generated by the code in this chapter.** - -This is Fabletext, a literate programming converter inspired by -[jupytext](https://github.com/mwouts/jupytext) and -[FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/). -It's written in F#, compiled to Python via Fable, and it processes the -`.fs` files that make up this blog - including itself. - -The chain goes like this: - -1. Each chapter is an F# file with embedded Markdown comments -2. Fable compiles the F# to Python -3. Fabletext (this code, running as Python) extracts the documentation -4. The output is the Markdown you're reading right now - -It's a strange loop - the snake eating its tail. And it proves that -Fable.Python isn't just a toy: you're looking at a real project that works. - -## How It Works - -The converter is a simple state machine that processes input line by line: - -- Lines inside `(** ... *)` blocks are emitted as Markdown -- F# code outside those blocks is wrapped in fenced code blocks -- `(*** hide ***)` sections are excluded from output -- `(*** include-python: symbol1, symbol2 ***)` extracts generated Python code - -## Parser State - -We track three possible states as we scan through the file: - -```fsharp -/// Represents the current state of the parser state machine. -type ParserState = - /// Inside a markdown block (** ... *) - | InMarkdown - /// Regular F# code outside markdown blocks - | InCode - /// Hidden section after (*** hide ***), content is skipped - | Hidden -``` - -## Line Classification - -Each line is classified using an active pattern to determine how to handle it. -The pattern also extracts content from markdown start lines: - -```fsharp -/// Converts a camelCase string to snake_case. -/// Uses the same algorithm as Fable's dashify function. -/// PascalCase names (starting with uppercase) are preserved as-is. -let toPythonNaming (name: string) : string = - if name.Length > 0 && Char.IsLower(name.[0]) then - System.Text.RegularExpressions.Regex.Replace( - name, - "[a-z]?[A-Z]", - fun m -> - if m.Value.Length = 1 then - m.Value.ToLowerInvariant() - else - m.Value.Substring(0, 1) + "_" + m.Value.Substring(1, 1).ToLowerInvariant() - ) - else - name - -/// Parses a comma-separated list of symbols from an include-python directive. -let parseSymbolList (directive: string) : string list = - // Extract content between "(*** include-python:" and "***)" - let start = "(*** include-python:".Length - let endPos = directive.LastIndexOf("***)") - if endPos > start then - directive.Substring(start, endPos - start).Trim() - |> fun s -> s.Split(',') - |> Array.map (fun s -> s.Trim()) - |> Array.filter (fun s -> s.Length > 0) - |> Array.toList - else - [] - -/// Active pattern for classifying source lines. -/// - `HideCmd`: The (*** hide ***) directive -/// - `IncludePythonCmd symbols`: The (*** include-python: sym1, sym2 ***) directive -/// - `MarkdownSingle content`: Single-line markdown (** content *) -/// - `MarkdownOpen content`: Start of markdown block, possibly with content -/// - `MarkdownClose`: End of markdown block *) -/// - `Content`: Any other line -let (|HideCmd|IncludePythonCmd|MarkdownSingle|MarkdownOpen|MarkdownClose|Content|) (line: string) = - let trimmed = line.Trim() - - match trimmed with - | "(*** hide ***)" -> HideCmd - | s when s.StartsWith("(*** include-python:") && s.EndsWith("***)") -> - IncludePythonCmd(parseSymbolList s) - | s when s.StartsWith("(**") && s.EndsWith("*)") && s.Length > 5 -> - MarkdownSingle(s.Substring(3, s.Length - 5).Trim()) - | s when s.StartsWith("(**") -> - let content = if s.Length > 3 then s.Substring(3).Trim() else "" - MarkdownOpen content - | "*)" -> MarkdownClose - | _ -> Content -``` - -## State Transitions - -The heart of the parser - handling transitions between states: - -```fsharp -/// The parsing context that tracks state, buffered code, and output. -type ParseContext = { - /// Current parser state - State: ParserState - /// Accumulated code lines waiting to be flushed - CodeBuffer: string list - /// Accumulated output chunks (in reverse order) - Output: string list -} - -/// Initial empty parsing context. -let emptyContext = { - State = InCode - CodeBuffer = [] - Output = [] -} - -/// Active pattern that matches strings starting with any of the given prefixes. -let (|StartsWithAny|_|) (prefixes: string list) (s: string) = - let trimmed = s.Trim() - - if prefixes |> List.exists trimmed.StartsWith then - Some() - else - None - -/// Boilerplate prefixes that should be excluded from code blocks. -let boilerplatePrefixes = [ "module "; "namespace " ] - -/// Flushes the code buffer to output as a fenced code block. -/// Skips empty or boilerplate-only code blocks. -let flushCodeBuffer (ctx: ParseContext) : ParseContext = - if ctx.CodeBuffer.IsEmpty then - ctx - else - let code = - ctx.CodeBuffer - |> List.rev - |> String.concat "\n" - |> fun s -> s.Trim() // Remove leading/trailing empty lines - // Skip empty, whitespace-only, or boilerplate code blocks - match code with - | s when String.IsNullOrWhiteSpace s -> { ctx with CodeBuffer = [] } - | StartsWithAny boilerplatePrefixes -> { ctx with CodeBuffer = [] } - | _ -> - // Add blank line before and after code block for markdown lint compliance - let block = $"\n```fsharp\n{code}\n```\n\n" - - { - ctx with - CodeBuffer = [] - Output = block :: ctx.Output - } - -/// Mutable storage for Python file content (set via CLI argument). -let mutable pythonFileContent: string option = None - -/// Checks if a line starts a new top-level definition (not indented). -/// Excludes closing brackets which are continuation of previous definitions. -let isTopLevelDefinition (line: string) : bool = - let trimmed = line.Trim() - not (String.IsNullOrWhiteSpace line) - && not (line.StartsWith " ") - && not (line.StartsWith "\t") - && not (line.StartsWith "#") - && not (trimmed = ")" || trimmed = "]" || trimmed = "}") - -/// Checks if a line is a decorator. -let isDecorator (line: string) : bool = - line.TrimStart().StartsWith "@" - -/// Checks if a line is a dunder method definition. -let isDunderMethod (line: string) : bool = - let trimmed = line.TrimStart() - trimmed.StartsWith "def __" - -/// Skips elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.skipWhile. -let arraySkipWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[idx..] - | None -> [||] - -/// Takes elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.takeWhile. -let arrayTakeWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[..idx - 1] - | None -> arr - -/// Extracts a single symbol definition from Python source lines. -/// Returns the definition including any decorators, stopping before dunder methods. -let extractSymbol (symbol: string) (lines: string array) : string option = - let symbolPatterns = [ - $"{symbol} ="; $"{symbol}: " - $"def {symbol}("; $"def {symbol}[" // Generic functions use [T] syntax - $"class {symbol}("; $"class {symbol}:"; $"class {symbol}[" - ] - - let matchesSymbol (line: string) = - let trimmed = line.TrimStart() - symbolPatterns |> List.exists trimmed.StartsWith - - lines - |> Array.tryFindIndex matchesSymbol - |> Option.map (fun defIndex -> - // Walk backwards to include decorators - // Find the first non-decorator line above defIndex, then start after it - let startIndex = - Seq.init defIndex (fun i -> defIndex - 1 - i) - |> Seq.tryFind (fun i -> not (isDecorator lines[i])) - |> Option.map ((+) 1) - |> Option.defaultValue 0 - - let defLine = lines[defIndex].TrimStart() - // Multi-line if: class/def, or assignment ending with open paren/bracket - let isMultiline = - defLine.StartsWith "class " - || defLine.StartsWith "def " - || defLine.EndsWith "(" - || defLine.EndsWith "[" - || defLine.EndsWith "{" - - if not isMultiline then - lines[defIndex] - else - // Take lines until we hit a new top-level def or dunder method - let shouldStop idx (line: string) = - idx > defIndex && (isTopLevelDefinition line || isDunderMethod line) - - lines[startIndex..] - |> Array.indexed - |> arrayTakeWhile (fun (i, line) -> not (shouldStop (startIndex + i) line)) - |> Array.map snd - |> Array.rev - |> arraySkipWhile String.IsNullOrWhiteSpace - |> Array.rev - |> String.concat "\n" - ) - -/// Extracts multiple symbols and combines them. -/// Converts F# symbol names to Python naming conventions. -let extractSymbols (symbols: string list) (pythonContent: string) : string = - let lines = pythonContent.Split('\n') - symbols - |> List.choose (fun sym -> extractSymbol (toPythonNaming sym) lines) - |> String.concat "\n\n" - -/// Processes a single line, updating the parse context based on state transitions. -let processLine (ctx: ParseContext) (line: string) : ParseContext = - match ctx.State, line with - // Entering hidden mode - | _, HideCmd -> { flushCodeBuffer ctx with State = Hidden } - - // Include Python symbols from transpiled output - | (InCode | Hidden), IncludePythonCmd symbols -> - let flushed = flushCodeBuffer ctx - match pythonFileContent with - | Some pythonContent -> - let extracted = extractSymbols symbols pythonContent - if extracted.Length > 0 then - let block = $"\n```python\n{extracted}\n```\n\n" - { flushed with Output = block :: flushed.Output } - else - flushed // No symbols found, emit nothing - | None -> - // No Python file provided, emit a placeholder comment - let symbolList = String.concat ", " symbols - let placeholder = $"\n\n" - { flushed with Output = placeholder :: flushed.Output } - - // Single-line markdown: (** content *) - | (InCode | Hidden), MarkdownSingle content -> - let flushed = flushCodeBuffer ctx - - { flushed with Output = (content + "\n") :: flushed.Output } - - // Starting markdown block with or without content - | (InCode | Hidden), MarkdownOpen content -> - let flushed = flushCodeBuffer ctx - - if content.Length > 0 then - { - flushed with - State = InMarkdown - Output = (content + "\n") :: flushed.Output - } - else - { flushed with State = InMarkdown } - - // Ending markdown block - | InMarkdown, MarkdownClose -> { ctx with State = InCode } - - // Content inside markdown - | InMarkdown, Content -> { ctx with Output = (line + "\n") :: ctx.Output } - - // Code line (not hidden) - | InCode, Content -> { ctx with CodeBuffer = line :: ctx.CodeBuffer } - - // Hidden content - skip - | Hidden, (Content | MarkdownClose) -> ctx - - // Ignore markdown markers in wrong state - | InMarkdown, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx - | InCode, MarkdownClose -> ctx -``` - -## Processing a File - -Read all lines, process them, and return the Markdown output: - -```fsharp -/// Processes all lines from a literate F# file and returns the Markdown output. -let processLines (lines: string seq) : string = - let finalCtx = lines |> Seq.fold processLine emptyContext |> flushCodeBuffer // Flush any remaining code - finalCtx.Output |> List.rev |> String.concat "" -``` - -## Including Generated Python Code - -One of Fabletext's unique features is the ability to show the generated Python -alongside the F# source. The include-python directive extracts specific symbols -from the transpiled output. - -When you pass --python-file path to fabletext, it reads the transpiled -Python and extracts the named symbols (functions, classes, or variables). -This lets readers see exactly what Python code Fable generates from the F#. - -The extraction is smart about Python syntax: - -- It finds the symbol definition by matching patterns like def symbol or class symbol -- It walks backwards to include any decorators -- For multi-line definitions, it captures everything until the next top-level definition -- It stops before dunder methods to avoid pulling in too much - -For example, the extractSymbol function in F# generates this Python: - -```python -def extract_symbol(symbol: str, lines: Array[str]) -> str | None: - """Extracts a single symbol definition from Python source lines. - Returns the definition including any decorators, stopping before dunder methods. - """ - symbol_patterns: FSharpList[str] = of_array_1( - Array[Any]( - [ - concat(symbol, " ="), - concat(symbol, ": "), - concat("def ", symbol, "("), - concat("def ", symbol, "["), - concat("class ", symbol, "("), - concat("class ", symbol, ":"), - concat("class ", symbol, "["), - ] - ) - ) - - def mapping_2(def_index: int32, symbol: Any = symbol, lines: Any = lines) -> str: - def mapping(y: int32, def_index: Any = def_index) -> int32: - return int32.ONE + y - - def predicate_1(i_1: int32, def_index: Any = def_index) -> bool: - return not is_decorator(lines[i_1]) - - def _arrow13(i: int32, def_index: Any = def_index) -> int32: - return (def_index - int32.ONE) - i - - start_index: int32 = default_arg( - map_1(mapping, try_find(predicate_1, initialize(def_index, _arrow13))), - int32.ZERO, - ) - def_line: str = lines[def_index].lstrip() - if not ( - True - if ( - True - if ( - True - if ( - True - if starts_with_exact(def_line, "class ") - else starts_with_exact(def_line, "def ") - ) - else ends_with_exact(def_line, "(") - ) - else ends_with_exact(def_line, "[") - ) - else ends_with_exact(def_line, "{") - ): - return lines[def_index] - - else: - - def predicate_3(value_2: str, def_index: Any = def_index) -> bool: - return is_null_or_white_space(value_2) - - def mapping_1(tuple: tuple[int32, str], def_index: Any = def_index) -> str: - return tuple[int32_1(1)] - - def predicate_2( - tupled_arg: tuple[int32, str], def_index: Any = def_index - ) -> bool: - def _arrow14(__unit: None = None, tupled_arg: Any = tupled_arg) -> bool: - line_1: str = tupled_arg[int32_1(1)] - return ( - ( - True - if is_top_level_definition(line_1) - else is_dunder_method(line_1) - ) - if ((start_index + tupled_arg[int32_1(0)]) > def_index) - else False - ) - - return not _arrow14() - - return join( - "\n", - reverse_1( - array_skip_while( - predicate_3, - reverse_1( - map( - mapping_1, - array_take_while( - predicate_2, - indexed(lines[start_index : len(lines)]), - ), - None, - ) - ), - ) - ), - ) - - def matches_symbol(line: str, symbol: Any = symbol, lines: Any = lines) -> bool: - trimmed: str = line.lstrip() - - def predicate(value: str, line: Any = line) -> bool: - return starts_with_exact(trimmed, value) - - return exists(predicate, symbol_patterns) - - return map_1(mapping_2, try_find_index(matches_symbol, lines)) -``` - -## Header Level Adjustment - -For concatenating multiple chapters into a single document, we need to -increase header levels (# becomes ##, ## becomes ###, etc.): - -```fsharp -/// Increases all markdown header levels by one (# becomes ##, etc.). -/// Preserves headers inside fenced code blocks. -let adjustHeaderLevels (markdown: string) : string = - let lines = markdown.Split('\n') - - let folder (inCodeBlock, acc) (line: string) = - match line with - | s when s.StartsWith("```") -> not inCodeBlock, line :: acc - | _ when inCodeBlock -> inCodeBlock, line :: acc - | s when s.StartsWith("#") -> inCodeBlock, ("#" + line) :: acc - | _ -> inCodeBlock, line :: acc - - lines |> Array.fold folder (false, []) |> snd |> List.rev |> String.concat "\n" -``` - -## Python File I/O - -For Fable.Python, we use Python's file operations: - -```fsharp -/// Reads the entire contents of a file as a string. -[] -let readFile (path: string) : string = nativeOnly - -/// Prints a string to stdout without a trailing newline. -[] -let printRaw (s: string) : unit = nativeOnly -``` - -## Main Entry Point - -Read the input file, convert it, and print the result: - -```fsharp -/// Gets the value following a flag argument (e.g., --python-file path.py). -let getFlagValue (flag: string) (args: string[]) : string option = - args - |> Array.tryFindIndex ((=) flag) - |> Option.bind (fun i -> - if i + 1 < args.Length then Some args.[i + 1] else None) - -/// Main entry point. Converts a literate F# file to Markdown. -/// Use --increase-headers flag to bump all header levels by one. -/// Use --python-file to enable include-python directives. -[] -let main (args: string[]) = - let hasFlag flag = args |> Array.contains flag - let pythonFilePath = getFlagValue "--python-file" args - // Filter out flags and their values - let files = - args - |> Array.indexed - |> Array.filter (fun (i, a) -> - not (a.StartsWith "--") - && not (i > 0 && args.[i - 1] = "--python-file")) - |> Array.map snd - - if files.Length < 1 then - printfn "Usage: python fabletext.py [--increase-headers] [--python-file ] " - 1 - else - // Load Python file content if provided - pythonFileContent <- - pythonFilePath - |> Option.map readFile - - let content = readFile files.[0] - let lines = content.Split('\n') - let markdown = processLines lines - - let output = - if hasFlag "--increase-headers" then - adjustHeaderLevels markdown - else - markdown - - printRaw output - 0 -``` - -## Building and Running - -```bash -# Transpile to Python -dotnet fable tools/ --lang python -o output/tools/ - -# Convert a literate file -python output/tools/fabletext.py chapters/introduction.fs > docs/introduction.md -``` - -That's it! A complete literate programming converter in under 200 lines of F#. - -## The Punchline - -If you're reading this, the code worked. - -This entire blog post - every chapter, every code example, every explanation - -was processed by the F# code you just read, compiled to Python, and output -as Markdown. The proof is in the reading. - -Welcome to Fable.Python. Now go build something. diff --git a/docs/getting-started.md b/docs/getting-started.md index e92bced..bda6f72 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,10 +23,10 @@ dotnet new console -lang F# # Set up local tools and install Fable 5 (alpha) dotnet new tool-manifest -dotnet tool install fable --version 5.0.0-alpha.17 +dotnet tool install fable --version 5.0.0-alpha.20 # Add Fable.Core package -dotnet add package Fable.Core --version 5.0.0-beta.2 +dotnet add package Fable.Core --version 5.0.0-beta.4 ``` ## Install Python Dependencies @@ -34,13 +34,13 @@ dotnet add package Fable.Core --version 5.0.0-beta.2 Fable-generated Python code requires the `fable-library` runtime: ```bash -pip install "fable-library==5.0.0a17" +pip install "fable-library==5.0.0a20" ``` --- **Note:** Version pinning matters. The fable-library version must match -your Fable compiler version. PyPI uses `5.0.0a17` format instead of `5.0.0-alpha.17`. +your Fable compiler version. PyPI uses `5.0.0a20` format instead of `5.0.0-alpha.20`. --- diff --git a/docs/python.md b/docs/python.md index 9745065..50de0bb 100644 --- a/docs/python.md +++ b/docs/python.md @@ -1,17 +1,17 @@ # Are You a Python Developer? -If you're coming from Python, welcome. This chapter will help you understand -the F# code you'll see throughout this guide. F# is more approachable than -it might appear, and many concepts are familiar. +If you're coming from Python, welcome. This chapter will help you understand the F# code +you'll see throughout this guide. F# is more approachable than it might appear, and many +concepts are familiar. ## What is F#? -F# is a functional-first language that runs on .NET. But here's the key insight -for you: **with Fable.Python, .NET is just a build tool**. You write F#, it -compiles to Python, and you run Python. Your deployment is pure Python. +F# is a functional-first language that runs on .NET. But here's the key insight for you: +**with Fable.Python, .NET is just a build tool**. You write F#, it compiles to Python, +and you run Python. Your deployment is pure Python. -Think of it like TypeScript for JavaScript - you get better tooling and type -safety during development, but the output is the language you know. +Think of it like TypeScript for JavaScript - you get better tooling and type safety +during development, but the output is the language you know. ## Key Concepts You'll See @@ -95,8 +95,8 @@ let area shape = | Rectangle(width, height) -> width * height ``` -The compiler warns you if you forget to handle a case. No more runtime -`AttributeError` because you forgot a shape type. +The compiler warns you if you forget to handle a case. No more runtime `AttributeError` +because you forgot a shape type. ### Records @@ -152,12 +152,14 @@ let numbers = [ -1; 2; -3; 4; 5 ] // F# pipeline - reads left to right, top to bottom let result = - numbers |> List.filter (fun x -> x > 0) |> List.map (fun x -> x * 2) |> List.sum + numbers + |> List.filter (fun x -> x > 0) + |> List.map (fun x -> x * 2) + |> List.sum ``` The `|>` operator takes the value on the left and passes it as the last -argument to the function on the right. It makes data transformations very -readable. +argument to the function on the right. It makes data transformations very readable. ### Option Types @@ -229,5 +231,5 @@ Your deployment, your dependencies, your runtime - all Python. ## Ready to Start? -Now that you understand the basics, let's set up your first Fable.Python project -in the next chapter! +Now that you understand the basics, let's set up your first Fable.Python project in the +next chapter! diff --git a/justfile b/justfile index 4612901..7c75418 100644 --- a/justfile +++ b/justfile @@ -21,11 +21,11 @@ restore: # Build all chapters to Python build: dotnet fable fable-python.fsproj --lang python -o output/chapters/ - dotnet fable tools/fabletext.fsproj --lang python -o output/tools/ + dotnet fable Fable.Literate/Fable.Literate.fsproj --lang python -o output/Fable.Literate/ # Build the converter only build-converter: - dotnet fable tools/fabletext.fsproj --lang python -o output/tools/ + dotnet fable Fable.Literate/Fable.Literate.fsproj --lang python -o output/Fable.Literate/ # Watch mode for development watch: @@ -38,16 +38,16 @@ generate: build format-python for name in {{chapters}}; do # Convert underscores in chapter name to match Python file naming pyname=$(echo "$name" | tr '-' '_') - uv run python output/tools/fabletext.py \ + uv run python output/Fable.Literate/app.py \ --python-file "output/chapters/chapters/${pyname}.py" \ "chapters/${name}.fs" > "docs/${name}.md" echo "Generated docs/${name}.md" done - # Also generate fabletext documentation - uv run python output/tools/fabletext.py \ - --python-file "output/tools/fabletext.py" \ - tools/fabletext.fs > docs/fabletext.md - echo "Generated docs/fabletext.md" + # Also generate Fable.Literate documentation + uv run python output/Fable.Literate/app.py \ + --python-file "output/Fable.Literate/python.py" \ + Fable.Literate/App.fs > docs/fable-literate.md + echo "Generated docs/fable-literate.md" # Fix markdown lint issues just lint-markdown @@ -61,23 +61,23 @@ blogpost: build format-python pyname=$(echo "$name" | tr '-' '_') if $first; then # First chapter keeps original header levels (has the title) - uv run python output/tools/fabletext.py \ + uv run python output/Fable.Literate/app.py \ --python-file "output/chapters/chapters/${pyname}.py" \ "chapters/${name}.fs" > docs/blogpost.md first=false else # Remaining chapters get headers increased by one level echo "" >> docs/blogpost.md - uv run python output/tools/fabletext.py \ + uv run python output/Fable.Literate/app.py \ --python-file "output/chapters/chapters/${pyname}.py" \ --increase-headers "chapters/${name}.fs" >> docs/blogpost.md fi done - # Include fabletext documenting itself (the meta twist!) + # Include Fable.Literate documenting itself (the meta twist!) echo "" >> docs/blogpost.md - uv run python output/tools/fabletext.py \ - --python-file "output/tools/fabletext.py" \ - --increase-headers tools/fabletext.fs >> docs/blogpost.md + uv run python output/Fable.Literate/app.py \ + --python-file "output/Fable.Literate/python.py" \ + --increase-headers Fable.Literate/App.fs >> docs/blogpost.md echo "Generated docs/blogpost.md" # Fix markdown lint issues just lint-markdown @@ -86,7 +86,7 @@ blogpost: build format-python generate-chapter chapter: build format-python #!/usr/bin/env bash pyname=$(echo "{{chapter}}" | tr '-' '_') - uv run python output/tools/fabletext.py \ + uv run python output/Fable.Literate/app.py \ --python-file "output/chapters/chapters/${pyname}.py" \ "chapters/{{chapter}}.fs" @@ -102,7 +102,7 @@ run file: # Format F# files with fantomas format-fsharp: - dotnet fantomas chapters/ tools/ + dotnet fantomas chapters/ Fable.Literate/ # Format Python files with ruff (ignore gitignore for generated files) format-python: diff --git a/tools/fabletext.fs b/tools/fabletext.fs deleted file mode 100644 index de866e8..0000000 --- a/tools/fabletext.fs +++ /dev/null @@ -1,468 +0,0 @@ -(** -# Fabletext: The Strange Loop - -You've made it to the end - and here's where things get delightfully meta. - -**The blog post you're reading was generated by the code in this chapter.** - -This is Fabletext, a literate programming converter inspired by -[jupytext](https://github.com/mwouts/jupytext) and -[FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/). -It's written in F#, compiled to Python via Fable, and it processes the -`.fs` files that make up this blog - including itself. - -The chain goes like this: - -1. Each chapter is an F# file with embedded Markdown comments -2. Fable compiles the F# to Python -3. Fabletext (this code, running as Python) extracts the documentation -4. The output is the Markdown you're reading right now - -It's a strange loop - the snake eating its tail. And it proves that -Fable.Python isn't just a toy: you're looking at a real project that works. - -## How It Works - -The converter is a simple state machine that processes input line by line: - -- Lines inside `(** ... *)` blocks are emitted as Markdown -- F# code outside those blocks is wrapped in fenced code blocks -- `(*** hide ***)` sections are excluded from output -- `(*** include-python: symbol1, symbol2 ***)` extracts generated Python code - -*) - -(*** hide ***) -open System -open Fable.Core - -(** -## Parser State - -We track three possible states as we scan through the file: -*) - -/// Represents the current state of the parser state machine. -type ParserState = - /// Inside a markdown block (** ... *) - | InMarkdown - /// Regular F# code outside markdown blocks - | InCode - /// Hidden section after (*** hide ***), content is skipped - | Hidden - -(** -## Line Classification - -Each line is classified using an active pattern to determine how to handle it. -The pattern also extracts content from markdown start lines: -*) - -/// Converts a camelCase string to snake_case. -/// Uses the same algorithm as Fable's dashify function. -/// PascalCase names (starting with uppercase) are preserved as-is. -let toPythonNaming (name: string) : string = - if name.Length > 0 && Char.IsLower(name.[0]) then - System.Text.RegularExpressions.Regex.Replace( - name, - "[a-z]?[A-Z]", - fun m -> - if m.Value.Length = 1 then - m.Value.ToLowerInvariant() - else - m.Value.Substring(0, 1) + "_" + m.Value.Substring(1, 1).ToLowerInvariant() - ) - else - name - -/// Parses a comma-separated list of symbols from an include-python directive. -let parseSymbolList (directive: string) : string list = - // Extract content between "(*** include-python:" and "***)" - let start = "(*** include-python:".Length - let endPos = directive.LastIndexOf("***)") - if endPos > start then - directive.Substring(start, endPos - start).Trim() - |> fun s -> s.Split(',') - |> Array.map (fun s -> s.Trim()) - |> Array.filter (fun s -> s.Length > 0) - |> Array.toList - else - [] - -/// Active pattern for classifying source lines. -/// - `HideCmd`: The (*** hide ***) directive -/// - `IncludePythonCmd symbols`: The (*** include-python: sym1, sym2 ***) directive -/// - `MarkdownSingle content`: Single-line markdown (** content *) -/// - `MarkdownOpen content`: Start of markdown block, possibly with content -/// - `MarkdownClose`: End of markdown block *) -/// - `Content`: Any other line -let (|HideCmd|IncludePythonCmd|MarkdownSingle|MarkdownOpen|MarkdownClose|Content|) (line: string) = - let trimmed = line.Trim() - - match trimmed with - | "(*** hide ***)" -> HideCmd - | s when s.StartsWith("(*** include-python:") && s.EndsWith("***)") -> - IncludePythonCmd(parseSymbolList s) - | s when s.StartsWith("(**") && s.EndsWith("*)") && s.Length > 5 -> - MarkdownSingle(s.Substring(3, s.Length - 5).Trim()) - | s when s.StartsWith("(**") -> - let content = if s.Length > 3 then s.Substring(3).Trim() else "" - MarkdownOpen content - | "*)" -> MarkdownClose - | _ -> Content - -(** -## State Transitions - -The heart of the parser - handling transitions between states: -*) - -/// The parsing context that tracks state, buffered code, and output. -type ParseContext = { - /// Current parser state - State: ParserState - /// Accumulated code lines waiting to be flushed - CodeBuffer: string list - /// Accumulated output chunks (in reverse order) - Output: string list -} - -/// Initial empty parsing context. -let emptyContext = { - State = InCode - CodeBuffer = [] - Output = [] -} - -/// Active pattern that matches strings starting with any of the given prefixes. -let (|StartsWithAny|_|) (prefixes: string list) (s: string) = - let trimmed = s.Trim() - - if prefixes |> List.exists trimmed.StartsWith then - Some() - else - None - -/// Boilerplate prefixes that should be excluded from code blocks. -let boilerplatePrefixes = [ "module "; "namespace " ] - -/// Flushes the code buffer to output as a fenced code block. -/// Skips empty or boilerplate-only code blocks. -let flushCodeBuffer (ctx: ParseContext) : ParseContext = - if ctx.CodeBuffer.IsEmpty then - ctx - else - let code = - ctx.CodeBuffer - |> List.rev - |> String.concat "\n" - |> fun s -> s.Trim() // Remove leading/trailing empty lines - // Skip empty, whitespace-only, or boilerplate code blocks - match code with - | s when String.IsNullOrWhiteSpace s -> { ctx with CodeBuffer = [] } - | StartsWithAny boilerplatePrefixes -> { ctx with CodeBuffer = [] } - | _ -> - // Add blank line before and after code block for markdown lint compliance - let block = $"\n```fsharp\n{code}\n```\n\n" - - { - ctx with - CodeBuffer = [] - Output = block :: ctx.Output - } - -/// Mutable storage for Python file content (set via CLI argument). -let mutable pythonFileContent: string option = None - -/// Checks if a line starts a new top-level definition (not indented). -/// Excludes closing brackets which are continuation of previous definitions. -let isTopLevelDefinition (line: string) : bool = - let trimmed = line.Trim() - not (String.IsNullOrWhiteSpace line) - && not (line.StartsWith " ") - && not (line.StartsWith "\t") - && not (line.StartsWith "#") - && not (trimmed = ")" || trimmed = "]" || trimmed = "}") - -/// Checks if a line is a decorator. -let isDecorator (line: string) : bool = - line.TrimStart().StartsWith "@" - -/// Checks if a line is a dunder method definition. -let isDunderMethod (line: string) : bool = - let trimmed = line.TrimStart() - trimmed.StartsWith "def __" - -/// Skips elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.skipWhile. -let arraySkipWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[idx..] - | None -> [||] - -/// Takes elements from the start of an array while the predicate is true. -/// Workaround until Fable.Python supports Array.takeWhile. -let arrayTakeWhile (predicate: 'a -> bool) (arr: 'a array) : 'a array = - match arr |> Array.tryFindIndex (predicate >> not) with - | Some idx -> arr[..idx - 1] - | None -> arr - -/// Extracts a single symbol definition from Python source lines. -/// Returns the definition including any decorators, stopping before dunder methods. -let extractSymbol (symbol: string) (lines: string array) : string option = - let symbolPatterns = [ - $"{symbol} ="; $"{symbol}: " - $"def {symbol}("; $"def {symbol}[" // Generic functions use [T] syntax - $"class {symbol}("; $"class {symbol}:"; $"class {symbol}[" - ] - - let matchesSymbol (line: string) = - let trimmed = line.TrimStart() - symbolPatterns |> List.exists trimmed.StartsWith - - lines - |> Array.tryFindIndex matchesSymbol - |> Option.map (fun defIndex -> - // Walk backwards to include decorators - // Find the first non-decorator line above defIndex, then start after it - let startIndex = - Seq.init defIndex (fun i -> defIndex - 1 - i) - |> Seq.tryFind (fun i -> not (isDecorator lines[i])) - |> Option.map ((+) 1) - |> Option.defaultValue 0 - - let defLine = lines[defIndex].TrimStart() - // Multi-line if: class/def, or assignment ending with open paren/bracket - let isMultiline = - defLine.StartsWith "class " - || defLine.StartsWith "def " - || defLine.EndsWith "(" - || defLine.EndsWith "[" - || defLine.EndsWith "{" - - if not isMultiline then - lines[defIndex] - else - // Take lines until we hit a new top-level def or dunder method - let shouldStop idx (line: string) = - idx > defIndex && (isTopLevelDefinition line || isDunderMethod line) - - lines[startIndex..] - |> Array.indexed - |> arrayTakeWhile (fun (i, line) -> not (shouldStop (startIndex + i) line)) - |> Array.map snd - |> Array.rev - |> arraySkipWhile String.IsNullOrWhiteSpace - |> Array.rev - |> String.concat "\n" - ) - -/// Extracts multiple symbols and combines them. -/// Converts F# symbol names to Python naming conventions. -let extractSymbols (symbols: string list) (pythonContent: string) : string = - let lines = pythonContent.Split('\n') - symbols - |> List.choose (fun sym -> extractSymbol (toPythonNaming sym) lines) - |> String.concat "\n\n" - -/// Processes a single line, updating the parse context based on state transitions. -let processLine (ctx: ParseContext) (line: string) : ParseContext = - match ctx.State, line with - // Entering hidden mode - | _, HideCmd -> { flushCodeBuffer ctx with State = Hidden } - - // Include Python symbols from transpiled output - | (InCode | Hidden), IncludePythonCmd symbols -> - let flushed = flushCodeBuffer ctx - match pythonFileContent with - | Some pythonContent -> - let extracted = extractSymbols symbols pythonContent - if extracted.Length > 0 then - let block = $"\n```python\n{extracted}\n```\n\n" - { flushed with Output = block :: flushed.Output } - else - flushed // No symbols found, emit nothing - | None -> - // No Python file provided, emit a placeholder comment - let symbolList = String.concat ", " symbols - let placeholder = $"\n\n" - { flushed with Output = placeholder :: flushed.Output } - - // Single-line markdown: (** content *) - | (InCode | Hidden), MarkdownSingle content -> - let flushed = flushCodeBuffer ctx - - { flushed with Output = (content + "\n") :: flushed.Output } - - // Starting markdown block with or without content - | (InCode | Hidden), MarkdownOpen content -> - let flushed = flushCodeBuffer ctx - - if content.Length > 0 then - { - flushed with - State = InMarkdown - Output = (content + "\n") :: flushed.Output - } - else - { flushed with State = InMarkdown } - - // Ending markdown block - | InMarkdown, MarkdownClose -> { ctx with State = InCode } - - // Content inside markdown - | InMarkdown, Content -> { ctx with Output = (line + "\n") :: ctx.Output } - - // Code line (not hidden) - | InCode, Content -> { ctx with CodeBuffer = line :: ctx.CodeBuffer } - - // Hidden content - skip - | Hidden, (Content | MarkdownClose) -> ctx - - // Ignore markdown markers in wrong state - | InMarkdown, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx - | InCode, MarkdownClose -> ctx - -(** -## Processing a File - -Read all lines, process them, and return the Markdown output: -*) - -/// Processes all lines from a literate F# file and returns the Markdown output. -let processLines (lines: string seq) : string = - let finalCtx = lines |> Seq.fold processLine emptyContext |> flushCodeBuffer // Flush any remaining code - finalCtx.Output |> List.rev |> String.concat "" - -(** -## Including Generated Python Code - -One of Fabletext's unique features is the ability to show the generated Python -alongside the F# source. The include-python directive extracts specific symbols -from the transpiled output. - -When you pass --python-file path to fabletext, it reads the transpiled -Python and extracts the named symbols (functions, classes, or variables). -This lets readers see exactly what Python code Fable generates from the F#. - -The extraction is smart about Python syntax: - -- It finds the symbol definition by matching patterns like def symbol or class symbol -- It walks backwards to include any decorators -- For multi-line definitions, it captures everything until the next top-level definition -- It stops before dunder methods to avoid pulling in too much - -For example, the extractSymbol function in F# generates this Python: -*) - -(*** include-python: extractSymbol ***) - -(** -## Header Level Adjustment - -For concatenating multiple chapters into a single document, we need to -increase header levels (# becomes ##, ## becomes ###, etc.): -*) - -/// Increases all markdown header levels by one (# becomes ##, etc.). -/// Preserves headers inside fenced code blocks. -let adjustHeaderLevels (markdown: string) : string = - let lines = markdown.Split('\n') - - let folder (inCodeBlock, acc) (line: string) = - match line with - | s when s.StartsWith("```") -> not inCodeBlock, line :: acc - | _ when inCodeBlock -> inCodeBlock, line :: acc - | s when s.StartsWith("#") -> inCodeBlock, ("#" + line) :: acc - | _ -> inCodeBlock, line :: acc - - lines |> Array.fold folder (false, []) |> snd |> List.rev |> String.concat "\n" - -(** -## Python File I/O - -For Fable.Python, we use Python's file operations: -*) - -/// Reads the entire contents of a file as a string. -[] -let readFile (path: string) : string = nativeOnly - -/// Prints a string to stdout without a trailing newline. -[] -let printRaw (s: string) : unit = nativeOnly - -(** -## Main Entry Point - -Read the input file, convert it, and print the result: -*) - -/// Gets the value following a flag argument (e.g., --python-file path.py). -let getFlagValue (flag: string) (args: string[]) : string option = - args - |> Array.tryFindIndex ((=) flag) - |> Option.bind (fun i -> - if i + 1 < args.Length then Some args.[i + 1] else None) - -/// Main entry point. Converts a literate F# file to Markdown. -/// Use --increase-headers flag to bump all header levels by one. -/// Use --python-file to enable include-python directives. -[] -let main (args: string[]) = - let hasFlag flag = args |> Array.contains flag - let pythonFilePath = getFlagValue "--python-file" args - // Filter out flags and their values - let files = - args - |> Array.indexed - |> Array.filter (fun (i, a) -> - not (a.StartsWith "--") - && not (i > 0 && args.[i - 1] = "--python-file")) - |> Array.map snd - - if files.Length < 1 then - printfn "Usage: python fabletext.py [--increase-headers] [--python-file ] " - 1 - else - // Load Python file content if provided - pythonFileContent <- - pythonFilePath - |> Option.map readFile - - let content = readFile files.[0] - let lines = content.Split('\n') - let markdown = processLines lines - - let output = - if hasFlag "--increase-headers" then - adjustHeaderLevels markdown - else - markdown - - printRaw output - 0 - -(** -## Building and Running - -```bash -# Transpile to Python -dotnet fable tools/ --lang python -o output/tools/ - -# Convert a literate file -python output/tools/fabletext.py chapters/introduction.fs > docs/introduction.md -``` - -That's it! A complete literate programming converter in under 200 lines of F#. - -## The Punchline - -If you're reading this, the code worked. - -This entire blog post - every chapter, every code example, every explanation - -was processed by the F# code you just read, compiled to Python, and output -as Markdown. The proof is in the reading. - -Welcome to Fable.Python. Now go build something. -*) diff --git a/tools/fabletext.fsproj b/tools/fabletext.fsproj deleted file mode 100644 index afebc96..0000000 --- a/tools/fabletext.fsproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net8.0 - preview - - - - - - - - - - - - From 8adffa169391b06b99cbcbc42c116cd80eb508be Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 17 Dec 2025 18:58:10 +0100 Subject: [PATCH 2/4] feat: async-programming --- .config/dotnet-tools.json | 2 +- chapters/async-programming.fs | 407 +++++++++++++++++++++++++++++++++ docs/async-programming.md | 412 ++++++++++++++++++++++++++++++++++ docs/compatibility.md | 2 + fable-python.fsproj | 5 +- justfile | 2 +- pyproject.toml | 2 +- uv.lock | 50 ++--- 8 files changed, 852 insertions(+), 30 deletions(-) create mode 100644 chapters/async-programming.fs create mode 100644 docs/async-programming.md diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 64fbe45..d7032a0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "fable": { - "version": "5.0.0-alpha.20", + "version": "5.0.0-alpha.21", "commands": [ "fable" ], diff --git a/chapters/async-programming.fs b/chapters/async-programming.fs new file mode 100644 index 0000000..b55a7b5 --- /dev/null +++ b/chapters/async-programming.fs @@ -0,0 +1,407 @@ +module AsyncProgramming + +(** +# Async Programming + +Asynchronous programming is essential for modern applications - from web APIs to data +processing pipelines. F# offers two models for async code: `async` workflows and `task` +expressions. Understanding when to use each is key to effective Fable.Python development. + +## Comparing Python and F`#` Async Models + +Python's async model is built on `asyncio`. Python coroutines are **cold** - calling an +`async def` function returns a coroutine object that doesn't execute until awaited: + +```python +import asyncio + +async def fetch_data(): + print("Starting") # Not printed when function is called! + await asyncio.sleep(1) + return "data" + +coro = fetch_data() # Returns coroutine, nothing executes yet +result = await coro # NOW "Starting" prints and code runs + +# Or more commonly: +asyncio.run(fetch_data()) +``` + +F# provides two computation expressions that compile to Python's async model: + +- **`async { }`** - F#'s original async workflows (cold, composable, multi-target) +- **`task { }`** - .NET-style tasks (hot in .NET, compiles to native `async def` in Python) + +## F`#` Async Workflows + +The `async` computation expression has been part of F# since the beginning. It creates +*cold* async operations - they don't start until explicitly run. +*) + +open System + +let fetchDataAsync () = + async { + do! Async.Sleep 1000 + return "data from async" + } + +(** +Key characteristics of `async`: + +- **Cold execution** - Nothing happens until you start it +- **Composable** - Combine with `Async.Parallel`, `Async.Sequential`, etc. +- **Multi-target** - The same code works on .NET, JavaScript, AND Python +- **Cancellation** - Built-in support via `CancellationToken` + +### Running Async Workflows + +There are several ways to execute an async workflow: +*) + +let runAsyncExample () = + // Start immediately (non-blocking) - Ignore discards the result + fetchDataAsync () |> Async.Ignore |> Async.StartImmediate + + // Run synchronously (blocking) + let result = fetchDataAsync () |> Async.RunSynchronously + + // Start with explicit continuations + Async.StartWithContinuations( + fetchDataAsync (), + (fun result -> printfn $"Success: {result}"), + (fun ex -> printfn $"Error: {ex.Message}"), + (fun cancelled -> printfn "Cancelled") + ) + +(** +### Combining Async Operations + +F# async shines when composing multiple operations: +*) + +let fetchMultipleAsync () = + async { + let! results = + [ fetchDataAsync () + fetchDataAsync () + fetchDataAsync () ] + |> Async.Parallel + + return results |> Array.toList + } + +(** +The `Async.Parallel` function runs all operations concurrently and waits for all to +complete. This is much cleaner than manually managing multiple coroutines in Python. + +### Error Handling in Async + +Use `try...with` inside async blocks or `Async.Catch` for explicit error handling: +*) + +let safeAsync () = + async { + try + do! Async.Sleep 100 + failwith "Something went wrong" + return "success" + with ex -> + return $"Error: {ex.Message}" + } + +let catchExample () = + async { + let! result = safeAsync () |> Async.Catch + + match result with + | Choice1Of2 value -> printfn $"Got: {value}" + | Choice2Of2 ex -> printfn $"Failed: {ex.Message}" + } + +(** +## F`#` Tasks + +The `task` computation expression in .NET creates *hot* tasks that start immediately. +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. +*) + +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 + return item.ToUpper() + } + +(** +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. + +### Task vs Async: Key Differences + +| Aspect | `async { }` | `task { }` | +| ---------------- | ----------------------- | -------------------------- | +| .NET execution | Cold (lazy) | Hot (immediate) | +| Python execution | Cold | Cold (coroutines are cold) | +| Python output | Wrapped awaitable | Native `async def` | +| Framework compat | Manual bridging | Direct (FastAPI, etc.) | +| Multi-target | .NET, JS, Python | .NET, Python | +| Composition | Rich (`Async.Parallel`) | Basic | + +> **Why the difference?** In .NET, an `async` method is still a regular method - when you +> call it, the method body starts executing immediately until it hits an `await`. The +> returned `Task` represents work already in progress. +> +> In Python, `async def` creates a *coroutine function*. Calling it doesn't run the body - +> it returns a coroutine object (a generator-like structure). This coroutine is just a +> "recipe" that must be driven by an event loop via `await` or `asyncio.run()`. +> +> When Fable compiles F# `task` to Python `async def`, the cold Python semantics apply. +> The advantage of `task` for Python is the native `async def` signature that frameworks +> recognize. + +### Working with Tasks +*) + +let taskExample () = + task { + let! result = fetchDataTask () + return $"Processed: {result}" + } + +let taskWithLoop () = + task { + let mutable sum = 0 + for i in 1..10 do + sum <- sum + i + return sum + } + +(** +## Mapping to Python + +Understanding how F# async constructs map to Python helps when debugging or integrating +with Python code. + +### Async Workflows → Python + +F# `async` workflows compile to a wrapped async structure: +*) + +let simpleAsync () = + async { + do! Async.Sleep 500 + return 42 + } + +(** +In Python, this generates: +*) + +(*** include-python: simpleAsync ***) + +(** +### Tasks → Native async def + +F# `task` expressions compile directly to Python's `async def`: +*) + +let simpleTask () = + task { + do! Task.Delay 500 + return 42 + } + +(** +In Python, this generates: +*) + +(*** include-python: simpleTask ***) + +(** +### Running in Python's Event Loop + +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) + +asyncio.run(main()) +``` + +For frameworks like FastAPI, the event loop is managed for you. + +## Practical Patterns + +### Async HTTP Requests + +Here's a pattern for async HTTP operations (assuming you have bindings for `aiohttp`): +*) + +// Simulated async HTTP - in real code you'd use aiohttp bindings +let fetchUrlAsync (url: string) = + async { + do! Async.Sleep 100 // Simulates network delay + return $"Response from {url}" + } + +let fetchMultipleUrls (urls: string list) = + async { + let! responses = + urls + |> List.map fetchUrlAsync + |> Async.Parallel + + return responses |> Array.toList + } + +(** +### Sequential vs Parallel + +Choose based on whether operations are independent: +*) + +let sequentialProcessing items = + async { + let results = ResizeArray() + for item in items do + let! result = fetchUrlAsync item + results.Add(result) + return results |> Seq.toList + } + +let parallelProcessing items = + async { + let! results = + items + |> List.map fetchUrlAsync + |> Async.Parallel + return results |> Array.toList + } + +(** +### Cancellation + +F# async supports cancellation via `CancellationToken`: +*) + +open System.Threading + +let cancellableWork (token: CancellationToken) = + async { + for i in 1..100 do + token.ThrowIfCancellationRequested() + do! Async.Sleep 50 + printfn $"Step {i}" + return "Completed" + } + +let runWithTimeout () = + async { + use cts = new CancellationTokenSource(2000) // 2 second timeout + try + let! result = cancellableWork cts.Token + return Some result + with + | :? OperationCanceledException -> + return None + } + +(** +## When to Use What + +### Use `task { }` for Python Interop + +When working with Python frameworks that expect native async functions: +*) + +// FastAPI endpoint (see FastAPI chapter) +let getItemTask (itemId: int) = + task { + do! Task.Delay 10 + return {| id = itemId; name = "Widget" |} + } + +(** +### Use `async { }` for Multi-Target Code + +When you want the same async code to work on Python, .NET, AND JavaScript: +*) + +// This code compiles to all Fable targets +let sharedBusinessLogic (input: string) = + async { + do! Async.Sleep 100 + let processed = input.ToUpper() + return processed + } + +(** +### Use `async { }` for Composition + +When you need rich composition primitives: +*) + +let complexWorkflow () = + async { + // Run three operations in parallel + let! results = + [ fetchDataAsync () + fetchDataAsync () + fetchDataAsync () ] + |> Async.Parallel + + // Then do something sequential + do! Async.Sleep 100 + + return results |> Array.toList + } + +(** +## Summary + +| Scenario | Recommendation | +| -------------------- | -------------- | +| FastAPI endpoints | `task { }` | +| aiohttp/asyncio libs | `task { }` | +| Multi-target library | `async { }` | +| Complex composition | `async { }` | +| Cancellation-heavy | `async { }` | +| Simple one-off async | Either works | + +The key insight: **`task` for Python-native `async def` integration (FastAPI, etc.), +`async` for Fable portability and rich composition**. Both are cold in Python. + +In the next chapter, we'll look at Fable v5 features that make Python development even +smoother. +*) diff --git a/docs/async-programming.md b/docs/async-programming.md new file mode 100644 index 0000000..f6ff43f --- /dev/null +++ b/docs/async-programming.md @@ -0,0 +1,412 @@ +# Async Programming + +Asynchronous programming is essential for modern applications - from web APIs to data +processing pipelines. F# offers two models for async code: `async` workflows and `task` +expressions. Understanding when to use each is key to effective Fable.Python development. + +## Comparing Python and F`#` Async Models + +Python's async model is built on `asyncio`. Python coroutines are **cold** - calling an +`async def` function returns a coroutine object that doesn't execute until awaited: + +```python +import asyncio + +async def fetch_data(): + print("Starting") # Not printed when function is called! + await asyncio.sleep(1) + return "data" + +coro = fetch_data() # Returns coroutine, nothing executes yet +result = await coro # NOW "Starting" prints and code runs + +# Or more commonly: +asyncio.run(fetch_data()) +``` + +F# provides two computation expressions that compile to Python's async model: + +- **`async { }`** - F#'s original async workflows (cold, composable, multi-target) +- **`task { }`** - .NET-style tasks (hot in .NET, compiles to native `async def` in Python) + +## F`#` Async Workflows + +The `async` computation expression has been part of F# since the beginning. It creates +*cold* async operations - they don't start until explicitly run. + +```fsharp +open System + +let fetchDataAsync () = + async { + do! Async.Sleep 1000 + return "data from async" + } +``` + +Key characteristics of `async`: + +- **Cold execution** - Nothing happens until you start it +- **Composable** - Combine with `Async.Parallel`, `Async.Sequential`, etc. +- **Multi-target** - The same code works on .NET, JavaScript, AND Python +- **Cancellation** - Built-in support via `CancellationToken` + +### Running Async Workflows + +There are several ways to execute an async workflow: + +```fsharp +let runAsyncExample () = + // Start immediately (non-blocking) - Ignore discards the result + fetchDataAsync () |> Async.Ignore |> Async.StartImmediate + + // Run synchronously (blocking) + let result = fetchDataAsync () |> Async.RunSynchronously + + // Start with explicit continuations + Async.StartWithContinuations( + fetchDataAsync (), + (fun result -> printfn $"Success: {result}"), + (fun ex -> printfn $"Error: {ex.Message}"), + (fun cancelled -> printfn "Cancelled") + ) +``` + +### Combining Async Operations + +F# async shines when composing multiple operations: + +```fsharp +let fetchMultipleAsync () = + async { + let! results = + [ fetchDataAsync () + fetchDataAsync () + fetchDataAsync () ] + |> Async.Parallel + + return results |> Array.toList + } +``` + +The `Async.Parallel` function runs all operations concurrently and waits for all to +complete. This is much cleaner than manually managing multiple coroutines in Python. + +### Error Handling in Async + +Use `try...with` inside async blocks or `Async.Catch` for explicit error handling: + +```fsharp +let safeAsync () = + async { + try + do! Async.Sleep 100 + failwith "Something went wrong" + return "success" + with ex -> + return $"Error: {ex.Message}" + } + +let catchExample () = + async { + let! result = safeAsync () |> Async.Catch + + match result with + | Choice1Of2 value -> printfn $"Got: {value}" + | Choice2Of2 ex -> printfn $"Failed: {ex.Message}" + } +``` + +## F`#` Tasks + +The `task` computation expression in .NET creates *hot* tasks that start immediately. +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. + +```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 + return item.ToUpper() + } +``` + +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. + +### Task vs Async: Key Differences + +| Aspect | `async { }` | `task { }` | +| ---------------- | ----------------------- | -------------------------- | +| .NET execution | Cold (lazy) | Hot (immediate) | +| Python execution | Cold | Cold (coroutines are cold) | +| Python output | Wrapped awaitable | Native `async def` | +| Framework compat | Manual bridging | Direct (FastAPI, etc.) | +| Multi-target | .NET, JS, Python | .NET, Python | +| Composition | Rich (`Async.Parallel`) | Basic | + +> **Why the difference?** In .NET, an `async` method is still a regular method - when you +> call it, the method body starts executing immediately until it hits an `await`. The +> returned `Task` represents work already in progress. +> +> In Python, `async def` creates a *coroutine function*. Calling it doesn't run the body - +> it returns a coroutine object (a generator-like structure). This coroutine is just a +> "recipe" that must be driven by an event loop via `await` or `asyncio.run()`. +> +> When Fable compiles F# `task` to Python `async def`, the cold Python semantics apply. +> The advantage of `task` for Python is the native `async def` signature that frameworks +> recognize. + +### Working with Tasks + +```fsharp +let taskExample () = + task { + let! result = fetchDataTask () + return $"Processed: {result}" + } + +let taskWithLoop () = + task { + let mutable sum = 0 + for i in 1..10 do + sum <- sum + i + return sum + } +``` + +## Mapping to Python + +Understanding how F# async constructs map to Python helps when debugging or integrating +with Python code. + +### Async Workflows → Python + +F# `async` workflows compile to a wrapped async structure: + +```fsharp +let simpleAsync () = + async { + do! Async.Sleep 500 + return 42 + } +``` + +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]: + return singleton.Return(int32(42)) + + return singleton.Bind(sleep(int32(500)), _arrow60) + + return singleton.Delay(_arrow61) +``` + +### Tasks → Native async def + +F# `task` expressions compile directly to Python's `async def`: + +```fsharp +let simpleTask () = + task { + do! Task.Delay 500 + return 42 + } +``` + +In Python, this generates: + +```python +async def simple_task() -> int: + await asyncio.sleep(0.5) + return 42 +``` + +### Running in Python's Event Loop + +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) + +asyncio.run(main()) +``` + +For frameworks like FastAPI, the event loop is managed for you. + +## Practical Patterns + +### Async HTTP Requests + +Here's a pattern for async HTTP operations (assuming you have bindings for `aiohttp`): + +```fsharp +// Simulated async HTTP - in real code you'd use aiohttp bindings +let fetchUrlAsync (url: string) = + async { + do! Async.Sleep 100 // Simulates network delay + return $"Response from {url}" + } + +let fetchMultipleUrls (urls: string list) = + async { + let! responses = + urls + |> List.map fetchUrlAsync + |> Async.Parallel + + return responses |> Array.toList + } +``` + +### Sequential vs Parallel + +Choose based on whether operations are independent: + +```fsharp +let sequentialProcessing items = + async { + let results = ResizeArray() + for item in items do + let! result = fetchUrlAsync item + results.Add(result) + return results |> Seq.toList + } + +let parallelProcessing items = + async { + let! results = + items + |> List.map fetchUrlAsync + |> Async.Parallel + return results |> Array.toList + } +``` + +### Cancellation + +F# async supports cancellation via `CancellationToken`: + +```fsharp +open System.Threading + +let cancellableWork (token: CancellationToken) = + async { + for i in 1..100 do + token.ThrowIfCancellationRequested() + do! Async.Sleep 50 + printfn $"Step {i}" + return "Completed" + } + +let runWithTimeout () = + async { + use cts = new CancellationTokenSource(2000) // 2 second timeout + try + let! result = cancellableWork cts.Token + return Some result + with + | :? OperationCanceledException -> + return None + } +``` + +## When to Use What + +### Use `task { }` for Python Interop + +When working with Python frameworks that expect native async functions: + +```fsharp +// FastAPI endpoint (see FastAPI chapter) +let getItemTask (itemId: int) = + task { + do! Task.Delay 10 + return {| id = itemId; name = "Widget" |} + } +``` + +### Use `async { }` for Multi-Target Code + +When you want the same async code to work on Python, .NET, AND JavaScript: + +```fsharp +// This code compiles to all Fable targets +let sharedBusinessLogic (input: string) = + async { + do! Async.Sleep 100 + let processed = input.ToUpper() + return processed + } +``` + +### Use `async { }` for Composition + +When you need rich composition primitives: + +```fsharp +let complexWorkflow () = + async { + // Run three operations in parallel + let! results = + [ fetchDataAsync () + fetchDataAsync () + fetchDataAsync () ] + |> Async.Parallel + + // Then do something sequential + do! Async.Sleep 100 + + return results |> Array.toList + } +``` + +## Summary + +| Scenario | Recommendation | +| -------------------- | -------------- | +| FastAPI endpoints | `task { }` | +| aiohttp/asyncio libs | `task { }` | +| Multi-target library | `async { }` | +| Complex composition | `async { }` | +| Cancellation-heavy | `async { }` | +| Simple one-off async | Either works | + +The key insight: **`task` for Python-native `async def` integration (FastAPI, etc.), +`async` for Fable portability and rich composition**. Both are cold in Python. + +In the next chapter, we'll look at Fable v5 features that make Python development even +smoother. diff --git a/docs/compatibility.md b/docs/compatibility.md index 4f10116..05993a5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -89,6 +89,7 @@ let numbers = [ 1; 2; 3; 4; 5 ] let mutableList = ResizeArray() ``` + ```python greeting: str = "Hello, Python!" @@ -163,6 +164,7 @@ let person = { } ``` + ```python @dataclass(eq=False, repr=False, slots=True) class Person(Record): diff --git a/fable-python.fsproj b/fable-python.fsproj index 3078d61..41295e9 100644 --- a/fable-python.fsproj +++ b/fable-python.fsproj @@ -1,13 +1,13 @@ - net8.0 + net10.0 preview - + @@ -17,6 +17,7 @@ + diff --git a/justfile b/justfile index 7c75418..f87aa69 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # Chapter order for documentation generation # Edit this list to reorder or add chapters -chapters := "introduction python getting-started interop bindings compatibility fable-v5 pydantic units-of-measure" +chapters := "introduction python getting-started interop bindings compatibility async-programming fable-v5 pydantic units-of-measure" # Default: show help default: diff --git a/pyproject.toml b/pyproject.toml index 9dd5e6a..8901542 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ ] license = {text = "ISC"} dependencies = [ - "fable-library==5.0.0a20", + "fable-library==5.0.0a21", "pydantic>=2.12.5", ] diff --git a/uv.lock b/uv.lock index 80db1e7..2a38ba3 100644 --- a/uv.lock +++ b/uv.lock @@ -13,32 +13,32 @@ wheels = [ [[package]] name = "fable-library" -version = "5.0.0a20" +version = "5.0.0a21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/71/d3c1e17e0fea982c3ef8b9ceb7aa5b6749db9124d3b555f73ab511c21284/fable_library-5.0.0a20.tar.gz", hash = "sha256:3bba05805c33b1ba4929ba069db4fb3522f1d2614230eeb838b28020107d49d1", size = 200300, upload-time = "2025-12-08T14:18:05.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fc/7509b9fa5c2fb47861ffd1bd866969bc8c5b8a576d739d617db6a741dc7f/fable_library-5.0.0a21.tar.gz", hash = "sha256:00574343fcf4da48509e5028b7b7083d12c8714924cd22ad7affbbbf49f7e0c4", size = 201498, upload-time = "2025-12-15T16:57:15.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/a3/5799ccb0be448ce473223dd686ff6a68e092b0de5bab30736af103f8d761/fable_library-5.0.0a20-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0c6522d0465192276e8b8de1b1cf9419744535fc7237bd228907167106c62b3f", size = 1661135, upload-time = "2025-12-08T14:17:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/67/d2/7c0c9adc74cca250ec1583fb34cd2da1a3871a54bd277a315052285271cb/fable_library-5.0.0a20-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98c8665f6702d3550479d473fa43b96e283b5b226248a4a7154fca52eabd7b9a", size = 1588822, upload-time = "2025-12-08T14:17:15.31Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d1/08a41f3044609e7bbea22d2c394b7630e7a20109c38f81e7068423e13eea/fable_library-5.0.0a20-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a721a2ece60069cfbcf814ce7356b3986c4ead23c76789263f7abc159b4c4107", size = 1706597, upload-time = "2025-12-08T14:16:14.637Z" }, - { url = "https://files.pythonhosted.org/packages/e3/42/f2c91c66cb83e19219a603f445b6482d7d8c38ce3c793f443d3425146e87/fable_library-5.0.0a20-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70aa4f7f84e28c1e4247f1552a4ae66afbd1397bb3d301f6ad4914451f4cdca6", size = 1661074, upload-time = "2025-12-08T14:16:26.097Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d4/ee641c8fa490c8ee363db0210d17727ce83b64cad6661bb3b1b6ae800d30/fable_library-5.0.0a20-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:490c332ca5b7075aa08de4efdac1807766713b392d431af2dad82242046bc193", size = 1887218, upload-time = "2025-12-08T14:16:38.352Z" }, - { url = "https://files.pythonhosted.org/packages/b7/ca/7139e4c11554d9e7b72515c140340adf6f6ebe65deaf5071a8272909e638/fable_library-5.0.0a20-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a909bc7fa5f8f0dccbdeb5a71d2fb1fd941905b6e18a6439c3a9198ff5485ad3", size = 1802009, upload-time = "2025-12-08T14:16:49.119Z" }, - { url = "https://files.pythonhosted.org/packages/08/d0/02cd919390bc16924e341f2182745da509a0355df061a66f3b9cd9e3cfd6/fable_library-5.0.0a20-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ae7ca54f20ccc8f8ccfef6938830c6ccdaa19262f55713d1244550e3175e4a0", size = 1708040, upload-time = "2025-12-08T14:17:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/2c3425a07a577cfb85e3f7dba23efd4023bb797ff1119b67d38c8b6915d3/fable_library-5.0.0a20-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:90ee6c863d5e8fff8a659bbae8715d33a3eb387950926e04c89ec4650d1fb716", size = 1814233, upload-time = "2025-12-08T14:17:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/1f/cb/5cce1721473c5fcaaefc70258cedc500243230cfa7d3ccefa2abfa3dc007/fable_library-5.0.0a20-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb04fd4a86d7b846f5577879d388156fd64495c8f2bc3c23f8ff1fa66d1416c3", size = 1886419, upload-time = "2025-12-08T14:17:27.698Z" }, - { url = "https://files.pythonhosted.org/packages/09/66/ce8c0ea5282eae7478ea53fe4581899c0da79aabc885ced640f896fac284/fable_library-5.0.0a20-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:af04e6581136ba5d1d87828d84a65a7f1f0b7d0f463e148a9a83af9fbc75b32b", size = 1928128, upload-time = "2025-12-08T14:17:38.183Z" }, - { url = "https://files.pythonhosted.org/packages/66/45/b2725a1179fc70ed961b1741f61851417ffdfc97002d8edbf0fa35a5d767/fable_library-5.0.0a20-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b4fb050e549ae347b3198e204a52dc8533b64598f56fcc418aa43b8e86f2d6ee", size = 1925428, upload-time = "2025-12-08T14:17:49.853Z" }, - { url = "https://files.pythonhosted.org/packages/62/26/302ff5b84aa6ae1e2c2b3a4a5a5c04ebd8b97be145bfb357d7971311de91/fable_library-5.0.0a20-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c272da657429e0afc343e7045a596717f9a0e1c5d331e83793e45d61101f2ae1", size = 1930167, upload-time = "2025-12-08T14:18:00.538Z" }, - { url = "https://files.pythonhosted.org/packages/ee/16/fb0238c7b5707b4f51b1702a5840625c85d36a23f33b239edaded66d2b54/fable_library-5.0.0a20-cp314-cp314-win32.whl", hash = "sha256:f55c02d8bf2965936b308fe5760d39c77a71f03db8b89c8be31fc1cde79b9aa0", size = 1296393, upload-time = "2025-12-08T14:18:12.915Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/12e33d3121e324eb6b009192c0b60200bf81064815e0d6d797e628f6d124/fable_library-5.0.0a20-cp314-cp314-win_amd64.whl", hash = "sha256:d03ec6bf28c858c2ad3b33df552b0248b9143c2d798ab88cb21076efc5d41f74", size = 1429532, upload-time = "2025-12-08T14:18:11.754Z" }, - { url = "https://files.pythonhosted.org/packages/f2/87/14aa99d1831dd03006daf7b3d534ca0065ee17d420419a659d3227a7076f/fable_library-5.0.0a20-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf970ea08a40470ad69e6481ff02806d55695ac37aca80cec415e37cc0b4dff8", size = 1714205, upload-time = "2025-12-08T14:16:16.019Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/6cfc6d504dc56ff9c02e22b072ff5f9b4bdb1a528905e33e5da365887762/fable_library-5.0.0a20-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e4a92ba7a3c69f00d31ab68e8144629c122f02d8c6a2a1d89c0498fc42ed68e4", size = 1679306, upload-time = "2025-12-08T14:16:27.281Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b5/a443520cdfa9c3c66c957715239b519c6430e91d91ede4b8809843a35af1/fable_library-5.0.0a20-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9159b1450974f49cb4a53b6ac2b70c0fd5feacfb10daa1bb37fc3852bbef52a9", size = 1898090, upload-time = "2025-12-08T14:16:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/93/17/a136966a847cdcfdfacf1d3abd385bf6ca008bae00a09505e5161e2f8efa/fable_library-5.0.0a20-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a0b9db1a02cc9ac29949a35bd6420f41b9fe655d7738a413f862e68b211dc04", size = 1807667, upload-time = "2025-12-08T14:16:50.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5f/43bc41b370ebf17a2f732267858685f751b05f1c7771549e3457da13e9ac/fable_library-5.0.0a20-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6f72cb71a084a5b17a43491be56b75a794cc2f38c99d6d30f2d563e8c230b068", size = 1894424, upload-time = "2025-12-08T14:17:28.886Z" }, - { url = "https://files.pythonhosted.org/packages/d2/45/4ee0911f6ac6edb99b2db1faf2fcba59ce1a97314a7ccd2722623fca4ffb/fable_library-5.0.0a20-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a59776aaf1f656a11ae2b96444198a816bc7080a222cb9f9e8dd4cf96b70796", size = 1942243, upload-time = "2025-12-08T14:17:39.519Z" }, - { url = "https://files.pythonhosted.org/packages/b0/30/543414b7ea9a5ffcd88e694269a00cb4d3d387d04dc4a3afb065ef28d64c/fable_library-5.0.0a20-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ced671ccdbbd4b4863334bf207dfc40a4fd44ae9c73e4ffcf92ec2e81a13f04f", size = 1936812, upload-time = "2025-12-08T14:17:51.26Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c4/31cee9d7c8f72ffebe793d5aa6fd317152fbdc841666e5035bcfb2a6e03c/fable_library-5.0.0a20-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:987b6f9aeb729390979714c943fb56d2bbbbf19271d839c080c34ac9b6e9af8b", size = 1944870, upload-time = "2025-12-08T14:18:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/33/dd/f1e70daef771d24298cb89b0b61bd6d880dc4e259c4f58eb99eec92205a4/fable_library-5.0.0a21-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:accc2f6316c8930139aa670a0ac23f87b2bcafb87c8b541ad6384b4c028836c1", size = 1663860, upload-time = "2025-12-15T16:56:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/41/e4/2d318dd6f36c03e40a1d14a61c7583fa96682a515ae56a2d2bff5a27f30a/fable_library-5.0.0a21-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f3ee5298a93143ce4ade9ecbf81abf46aac620378ae8233a14d832a956aca82e", size = 1590274, upload-time = "2025-12-15T16:56:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5c/c2d53d8e864d1113ee48ad50e5260fbc0643d1d3e0ed41306479892d2f07/fable_library-5.0.0a21-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215b9db35b91a424de668e5654f0293418e2158137b969bf472f5a7d4d67d71f", size = 1678348, upload-time = "2025-12-15T16:55:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/37/ee/227759372e728c0aaf121a421ead522ba26d3a01141d9f50245d7a674135/fable_library-5.0.0a21-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c3e4efa2167363af0d6e4e9466ea239b249bd51646c03eca5c9ed5e49915994", size = 1643919, upload-time = "2025-12-15T16:55:33.635Z" }, + { url = "https://files.pythonhosted.org/packages/5b/78/3910761940fa34cd8bf13a1fda7ca1509949661b0d06968f1bf53307fb81/fable_library-5.0.0a21-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d92f886c089ce2326e46af7b28b1df9b117b19316ae02c9b5f963971d0f6091b", size = 1865150, upload-time = "2025-12-15T16:55:45.138Z" }, + { url = "https://files.pythonhosted.org/packages/12/6b/f1753f2d8ee8ce8b81208e4eb0699cb4f4a61984a1bf5b7a46dc89c63cf5/fable_library-5.0.0a21-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:836c8006897fa244461a94f04c4767d785a69f1b326399ce1fa479180c452fa3", size = 1786522, upload-time = "2025-12-15T16:55:56.611Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a3/7d9939c82f216f0b6a2ebfee8a7b4df918c4d2b0fb95608501c1a8e994e5/fable_library-5.0.0a21-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b951bbe9397546d65bf9b1360c912d083105405ceaa2e885189bdf19ee75f0b", size = 1701704, upload-time = "2025-12-15T16:56:14.862Z" }, + { url = "https://files.pythonhosted.org/packages/99/77/521ee201047fe4b98b29f944eb691a731943b417790951d95124fc91bf0e/fable_library-5.0.0a21-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e45e95f60c9bca40af58384c0617065bde8e65708e77f7193c87d77e4596d883", size = 1796899, upload-time = "2025-12-15T16:56:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/a77654fcbaedc31449d1ac7d5fed6a510a05958b17c4de7f111add0bb2f8/fable_library-5.0.0a21-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe04a27c1423df748c3b25025412b88e181f9fc2bc5237608baf7ad63e7833ba", size = 1859116, upload-time = "2025-12-15T16:56:36.099Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/65b515c333a1d2f788deadda45d96c85879784f6d583b6358a7a8f478bb5/fable_library-5.0.0a21-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:124b6545eaf5a11a227b6119d4cb9af84b7d0438c5ce08237eca843496577359", size = 1912104, upload-time = "2025-12-15T16:56:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/46/bc/5e5b81d335e237c0bd5acd222c7735ce96dc018576df3962be2a2b7f034c/fable_library-5.0.0a21-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:011218cbbee08aa48dd2f756e764f3225ea9b3b33a0c1b6ff6c1c7195f05f174", size = 1947890, upload-time = "2025-12-15T16:56:59.494Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/c9cb74aa05ab002b7a4ea08cc779806f751e11cc1a051e91725850fb4354/fable_library-5.0.0a21-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ef6db0292ff1908a28f439f0daf980b32731b83506f35a68ed2a83aa0fc33b34", size = 1960308, upload-time = "2025-12-15T16:57:11.115Z" }, + { url = "https://files.pythonhosted.org/packages/b1/65/e30cd470316c7e3761d9e71de8b6cefe9b8b92cf176a7e2cc9aae3831554/fable_library-5.0.0a21-cp314-cp314-win32.whl", hash = "sha256:e2ed7dfd4d1a7bcb6725910e98a41603210bd7cb882ac8765c996a1e596b641c", size = 1296054, upload-time = "2025-12-15T16:57:24.756Z" }, + { url = "https://files.pythonhosted.org/packages/38/22/9a2f671451fc8543071b82f61ca17e587257aead8ac4f09a079904eabd53/fable_library-5.0.0a21-cp314-cp314-win_amd64.whl", hash = "sha256:699a3e52a07c0a1499eee5d263f1378f217eea660f98446e1691c536b600e40c", size = 1430173, upload-time = "2025-12-15T16:57:23.448Z" }, + { url = "https://files.pythonhosted.org/packages/6b/09/ce3523dec497043b0e9a0441d5c6ff6e63f04bacccb13cd1dab6d77fb75f/fable_library-5.0.0a21-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63040652d4f85962959aa9a19be84c8252ebbff5346f5da6c467a5205e6938a6", size = 1686402, upload-time = "2025-12-15T16:55:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/0e867a6626660746c626524cd1d9d36bf88877f42472f424b4dabded8e0f/fable_library-5.0.0a21-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5577775317cb2b65c75fdc78d85bdbf3ffc233bbbb87010bb5e2ccb4c3ed20eb", size = 1663136, upload-time = "2025-12-15T16:55:35.126Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3f/e8bc08090c0bc880f36511c7f0f22ef2114f2e17fe789df5d9c39eb27b78/fable_library-5.0.0a21-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26bfd067f4047e08de1a8611aef3db8a3faf60e8c5d3cfbe39c98ab84ce842f4", size = 1878910, upload-time = "2025-12-15T16:55:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/5122ce7e4d37d6b21ff281701cf2eca9bba5b9c3858c13d11854102355f7/fable_library-5.0.0a21-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc9ed260c6d55d4030f51794e4488535cd262bb43e758b6cdd2c6b0247ce5cff", size = 1804657, upload-time = "2025-12-15T16:55:57.912Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cf/0d19a7f1480eb1f92b348eae0391484529d3ee1aa4d0d437536bcdf3ff9c/fable_library-5.0.0a21-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7ce7de39cb95cc206fd08329a3201a9901580df702ab74389033f42a6cf7a87b", size = 1866832, upload-time = "2025-12-15T16:56:37.735Z" }, + { url = "https://files.pythonhosted.org/packages/45/d8/e157856b165bfe92a82f667ca3023c82b99325cbe9ee400eed07f2cb0d69/fable_library-5.0.0a21-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7f3d7471b9968f6d96a861b8e303f6ef2f3d026fc43fc6e3fea405e4621f5c82", size = 1935055, upload-time = "2025-12-15T16:56:49.427Z" }, + { url = "https://files.pythonhosted.org/packages/e6/41/079e5961ec12cb4b6eb65fb81fd115b94c7b4ef4ff01c558070c76e08982/fable_library-5.0.0a21-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f926ba42a34c30eb2ab049bea4c7a16c1b37258a7be0b4c9dd7eb9674d19784c", size = 1964439, upload-time = "2025-12-15T16:57:00.791Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/e7e892a9ee688e808c4940dd912f108620e3fb791777345df6c63fd06de0/fable_library-5.0.0a21-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72b34f003778d7952c896aa20eae5bdd575e983d71633c6a44e35753d002c4d2", size = 1976265, upload-time = "2025-12-15T16:57:12.646Z" }, ] [[package]] @@ -57,7 +57,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fable-library", specifier = "==5.0.0a20" }, + { name = "fable-library", specifier = "==5.0.0a21" }, { name = "pydantic", specifier = ">=2.12.5" }, ] From a098b8c6f099ee3a49ecb91734e8049dc8a341fd Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 17 Dec 2025 19:01:50 +0100 Subject: [PATCH 3/4] build: Fix reference to Fable.Python --- Fable.Literate/Fable.Literate.fsproj | 3 +-- pyproject.toml | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Fable.Literate/Fable.Literate.fsproj b/Fable.Literate/Fable.Literate.fsproj index f5d32a2..5022a0a 100644 --- a/Fable.Literate/Fable.Literate.fsproj +++ b/Fable.Literate/Fable.Literate.fsproj @@ -8,8 +8,7 @@ - - + diff --git a/pyproject.toml b/pyproject.toml index 8901542..5c5f63c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,6 @@ dev = [ [tool.ruff] exclude = ["fable_modules"] + +[tool.ruff.lint] +ignore = ["F841"] # Unused variable - common in generated Fable code From 6d98c9493a3476da4b6df1c0c8725ce38a3da6d5 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 17 Dec 2025 21:20:23 +0100 Subject: [PATCH 4/4] ci: disable ruff linting for now --- docs/async-programming.md | 8 +------- docs/compatibility.md | 17 +++++++++++------ docs/fable-literate.md | 23 ++++++++++++----------- docs/interop.md | 16 ++++++++++------ docs/introduction.md | 5 +++-- docs/python.md | 32 +++++++++++++++----------------- justfile | 3 ++- pyproject.toml | 5 ++++- 8 files changed, 58 insertions(+), 51 deletions(-) diff --git a/docs/async-programming.md b/docs/async-programming.md index f6ff43f..e0a7bd5 100644 --- a/docs/async-programming.md +++ b/docs/async-programming.md @@ -243,13 +243,7 @@ let simpleTask () = ``` In Python, this generates: - -```python -async def simple_task() -> int: - await asyncio.sleep(0.5) - return 42 -``` - + ### Running in Python's Event Loop When your compiled Python code runs, you'll need an event loop. For scripts: diff --git a/docs/compatibility.md b/docs/compatibility.md index 05993a5..bb4d782 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -89,7 +89,6 @@ let numbers = [ 1; 2; 3; 4; 5 ] let mutableList = ResizeArray() ``` - ```python greeting: str = "Hello, Python!" @@ -164,7 +163,6 @@ let person = { } ``` - ```python @dataclass(eq=False, repr=False, slots=True) class Person(Record): @@ -227,16 +225,23 @@ let mapOps = Map.ofList [ ("a", 1); ("b", 2) ] ### Options Are Erased -Options are optimized away at runtime: +Options are erased at runtime, which is actually a feature rather than a limitation. +This makes interop with Python libraries seamless - you can pass F# option values +directly to Python functions expecting `T | None`: ```fsharp let someValue = Some 42 // Compiles to just: 42 let noneValue = None // Compiles to: None ``` -Note that Fable.Python uses a `SomeWrapper` class to handle nested options correctly. -`Some None` compiles to `SomeWrapper(None)`, which is distinct from plain `None`. -This means `Some (Some x)`, `Some None`, and `None` are all properly distinguishable. +This erasure means Python code receives native values without any wrapper overhead. +When calling a Python library that returns `Optional[T]`, you get values that work +directly with F# pattern matching. + +For the rare edge case of nested options (`Option>`), Fable.Python uses +a `SomeWrapper` to distinguish `Some None` from `None`. However, nested options +are uncommon in practice - the F# compiler warns about them in type annotations, +and well-designed library bindings avoid exposing them at API boundaries. ### Multi-line Lambdas diff --git a/docs/fable-literate.md b/docs/fable-literate.md index 38637f6..7a3476f 100644 --- a/docs/fable-literate.md +++ b/docs/fable-literate.md @@ -67,10 +67,6 @@ Utility functions for naming conversion and line classification: The parser converts source lines into a Block AST using a fold: -## PythonExtract Module - -Extracts symbol definitions from transpiled Python source code: - ## Transform Module Pure transformations on the document AST: @@ -103,18 +99,18 @@ The extraction is smart about Python syntax: For example, the extractSymbol function in F# generates this Python: ```python -def PythonExtract_extractSymbol(symbol: str, lines: Array[str]) -> str | None: +def extract_symbol(symbol: str, lines: Array[str]) -> str | None: """Extracts a single symbol definition from Python source lines.""" def mapping(def_index: int32, symbol: Any = symbol, lines: Any = lines) -> str: - start_index: int32 = PythonExtract_findDecoratorStart(lines, def_index) - if PythonExtract_isMultilineDefinition(lines[def_index]): - return PythonExtract_extractMultilineBody(start_index, def_index, lines) + start_index: int32 = find_decorator_start(lines, def_index) + if is_multiline_definition(lines[def_index]): + return extract_multiline_body(start_index, def_index, lines) else: return lines[def_index] - return map_1(mapping, PythonExtract_findDefinitionIndex(symbol, lines)) + return map(mapping, find_definition_index(symbol, lines)) ``` ## Main Entry Point @@ -124,19 +120,24 @@ Read the input file, convert it, and print the result: ```fsharp /// Gets the value following a flag argument (e.g., --python-file path.py). let getFlagValue (flag: string) (args: string[]) : string option = + // Find the index of the flag in args args |> Array.tryFindIndex ((=) flag) - |> Option.bind (fun i -> - if i + 1 < args.Length then Some args.[i + 1] else None) + // Return the next argument if it exists + |> Option.bind (fun i -> if i + 1 < args.Length then Some args.[i + 1] else None) /// Extracts positional arguments (file paths) from command line args. /// Filters out flags (--foo) and their values (--python-file path.py). let getPositionalArgs (args: string[]) : string[] = let isFlag (arg: string) = arg.StartsWith "--" let isValueOfFlag i = i > 0 && args.[i - 1] = "--python-file" + + // Pair each argument with its index args |> Array.indexed + // Keep only non-flags that aren't values of flags |> Array.filter (fun (i, arg) -> not (isFlag arg) && not (isValueOfFlag i)) + // Extract just the argument strings |> Array.map snd /// Main entry point. Converts a literate F# file to Markdown. diff --git a/docs/interop.md b/docs/interop.md index 77a7224..be5395a 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -203,7 +203,8 @@ For more complex Python code with statements: ```fsharp let factorial (count: int) : int = emitPyStatement - count """if $0 < 2: + count + """if $0 < 2: return 1 else: return $0 * factorial($0 - 1) @@ -219,7 +220,7 @@ let factorial (count: int) : int = type Direction = | North | South - | [] East // Custom string value + | [] East // Custom string value | West // North compiles to "north", East compiles to "E" @@ -232,13 +233,13 @@ Control the string format with `CaseRules`: ```fsharp [] type UserStatus = - | ActiveUser // -> "active_user" - | InactiveUser // -> "inactive_user" + | ActiveUser // -> "active_user" + | InactiveUser // -> "inactive_user" [] type CssBoxSizing = - | ContentBox // -> "content-box" - | BorderBox // -> "border-box" + | ContentBox // -> "content-box" + | BorderBox // -> "border-box" ``` Available case rules: `None`, `LowerFirst`, `SnakeCase`, `SnakeCaseAllCaps`, `KebabCase`, `LowerAll`. @@ -252,6 +253,7 @@ Erased unions let you create type-safe wrappers that disappear at runtime: type StringOrInt = | AsString of string | AsInt of int + member this.Describe() = match this with | AsString s -> $"String: {s}" @@ -274,6 +276,7 @@ You can create custom decorators that wrap functions at compile time: ```fsharp type LogAttribute(msg: string) = inherit Py.DecoratorAttribute() + override _.Decorate(fn) = Py.argsFunc (fun args -> printfn $"LOG: {msg}" @@ -360,6 +363,7 @@ Bind to Python global objects with the `Global` attribute: type PyList = [] abstract append: item: obj -> unit + [] abstract length: int ``` diff --git a/docs/introduction.md b/docs/introduction.md index 6564fd9..ae4bb37 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -24,8 +24,9 @@ With Fable.Python, you get all these benefits while targeting the Python ecosyst Fable.Python is a great choice when: -- **Python ecosystem access** - You need AI/ML libraries (PyTorch, TensorFlow, LangChain), - data science tools (Pandas, NumPy), or frameworks like Pydantic and FastAPI +- **Python ecosystem access** - You need AI/ML libraries (PyTorch, TensorFlow, + LangChain), data science tools (Pandas, NumPy), or frameworks like Pydantic and + FastAPI - **F# type safety** - You want pattern matching and exhaustive checking while using Python libraries - **Shared domain logic** - Write once in F#, run on .NET, JavaScript, Rust, and Python diff --git a/docs/python.md b/docs/python.md index 50de0bb..9745065 100644 --- a/docs/python.md +++ b/docs/python.md @@ -1,17 +1,17 @@ # Are You a Python Developer? -If you're coming from Python, welcome. This chapter will help you understand the F# code -you'll see throughout this guide. F# is more approachable than it might appear, and many -concepts are familiar. +If you're coming from Python, welcome. This chapter will help you understand +the F# code you'll see throughout this guide. F# is more approachable than +it might appear, and many concepts are familiar. ## What is F#? -F# is a functional-first language that runs on .NET. But here's the key insight for you: -**with Fable.Python, .NET is just a build tool**. You write F#, it compiles to Python, -and you run Python. Your deployment is pure Python. +F# is a functional-first language that runs on .NET. But here's the key insight +for you: **with Fable.Python, .NET is just a build tool**. You write F#, it +compiles to Python, and you run Python. Your deployment is pure Python. -Think of it like TypeScript for JavaScript - you get better tooling and type safety -during development, but the output is the language you know. +Think of it like TypeScript for JavaScript - you get better tooling and type +safety during development, but the output is the language you know. ## Key Concepts You'll See @@ -95,8 +95,8 @@ let area shape = | Rectangle(width, height) -> width * height ``` -The compiler warns you if you forget to handle a case. No more runtime `AttributeError` -because you forgot a shape type. +The compiler warns you if you forget to handle a case. No more runtime +`AttributeError` because you forgot a shape type. ### Records @@ -152,14 +152,12 @@ let numbers = [ -1; 2; -3; 4; 5 ] // F# pipeline - reads left to right, top to bottom let result = - numbers - |> List.filter (fun x -> x > 0) - |> List.map (fun x -> x * 2) - |> List.sum + numbers |> List.filter (fun x -> x > 0) |> List.map (fun x -> x * 2) |> List.sum ``` The `|>` operator takes the value on the left and passes it as the last -argument to the function on the right. It makes data transformations very readable. +argument to the function on the right. It makes data transformations very +readable. ### Option Types @@ -231,5 +229,5 @@ Your deployment, your dependencies, your runtime - all Python. ## Ready to Start? -Now that you understand the basics, let's set up your first Fable.Python project in the -next chapter! +Now that you understand the basics, let's set up your first Fable.Python project +in the next chapter! diff --git a/justfile b/justfile index f87aa69..5400280 100644 --- a/justfile +++ b/justfile @@ -120,7 +120,8 @@ lint-markdown: npx markdownlint --fix docs/*.md # Lint all generated files -lint: lint-python lint-markdown +# TODO: Re-enable lint-python once Fable code generation issues are fixed +lint: lint-markdown # Full build: restore, build, generate docs, format, lint all: restore build generate format lint diff --git a/pyproject.toml b/pyproject.toml index 5c5f63c..b3f91b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,4 +22,7 @@ dev = [ exclude = ["fable_modules"] [tool.ruff.lint] -ignore = ["F841"] # Unused variable - common in generated Fable code +ignore = [ + "F841", # Unused variable - common in generated Fable code + "F401", # Unused import - common in generated Fable code +]