From 823516dd2fcb51d5242d5ff517ecd6a7e5294cbe Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Mon, 9 Mar 2026 00:24:42 +0100 Subject: [PATCH] feat: Add threading, heapq, traceback bindings and extend builtins/queue Add new stdlib bindings so downstream projects don't need to create their own FFI wrappers for common Python modules. New modules: - Threading: Thread, Lock, RLock, Event classes + module functions - Heapq: heap operations (heappush, heappop, heapify, nlargest, etc.) - Traceback: print_exc, format_exc, print_stack, format_stack Extended existing modules: - Builtins: getattr, setattr, hasattr, isinstance, type - Queue: get_nowait(), Empty and Full exceptions Co-Authored-By: Claude Opus 4.6 --- src/Fable.Python.fsproj | 3 ++ src/stdlib/Builtins.fs | 29 +++++++++++++ src/stdlib/Heapq.fs | 34 +++++++++++++++ src/stdlib/Queue.fs | 49 ++++++++++++--------- src/stdlib/Threading.fs | 80 +++++++++++++++++++++++++++++++++++ src/stdlib/Traceback.fs | 25 +++++++++++ test/Fable.Python.Test.fsproj | 5 +++ test/TestBuiltinsAttr.fs | 27 ++++++++++++ test/TestHeapq.fs | 39 +++++++++++++++++ test/TestQueue.fs | 57 +++++++++++++++++++++++++ test/TestThreading.fs | 20 +++++++++ test/TestTraceback.fs | 15 +++++++ 12 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 src/stdlib/Heapq.fs create mode 100644 src/stdlib/Threading.fs create mode 100644 src/stdlib/Traceback.fs create mode 100644 test/TestBuiltinsAttr.fs create mode 100644 test/TestHeapq.fs create mode 100644 test/TestQueue.fs create mode 100644 test/TestThreading.fs create mode 100644 test/TestTraceback.fs diff --git a/src/Fable.Python.fsproj b/src/Fable.Python.fsproj index 34028ab..e7eb9a7 100644 --- a/src/Fable.Python.fsproj +++ b/src/Fable.Python.fsproj @@ -23,11 +23,14 @@ + + + diff --git a/src/stdlib/Builtins.fs b/src/stdlib/Builtins.fs index 89cffde..8260362 100644 --- a/src/stdlib/Builtins.fs +++ b/src/stdlib/Builtins.fs @@ -270,6 +270,26 @@ type IExports = ?opener: _Opener -> TextIOWrapper + /// Return the value of the named attribute of object. name must be a string. + /// See https://docs.python.org/3/library/functions.html#getattr + abstract getattr: obj: obj * name: string -> 'T + /// Return the value of the named attribute of object. If the attribute does not exist, + /// default is returned. + /// See https://docs.python.org/3/library/functions.html#getattr + abstract getattr: obj: obj * name: string * ``default``: 'T -> 'T + /// Sets the named attribute on the given object to the specified value. + /// See https://docs.python.org/3/library/functions.html#setattr + abstract setattr: obj: obj * name: string * value: obj -> unit + /// Return True if the string is the name of one of the object's attributes. + /// See https://docs.python.org/3/library/functions.html#hasattr + abstract hasattr: obj: obj * name: string -> bool + /// Return True if the object argument is an instance of the classinfo argument. + /// See https://docs.python.org/3/library/functions.html#isinstance + abstract isinstance: obj: obj * classinfo: obj -> bool + /// Return the type of an object. + /// See https://docs.python.org/3/library/functions.html#type + abstract ``type``: obj -> obj + [] let builtins: IExports = nativeOnly @@ -331,3 +351,12 @@ let __name__: string = nativeOnly /// Python print function. Takes a single argument, so can be used with e.g string interpolation. let print obj = builtins.print obj + +/// Return the value of the named attribute of object with a default. +let getattr obj name defaultValue = builtins.getattr (obj, name, defaultValue) + +/// Sets the named attribute on the given object to the specified value. +let setattr obj name value = builtins.setattr (obj, name, value) + +/// Return True if the string is the name of one of the object's attributes. +let hasattr obj name = builtins.hasattr (obj, name) diff --git a/src/stdlib/Heapq.fs b/src/stdlib/Heapq.fs new file mode 100644 index 0000000..1c8f0dc --- /dev/null +++ b/src/stdlib/Heapq.fs @@ -0,0 +1,34 @@ +/// Type bindings for Python heapq module: https://docs.python.org/3/library/heapq.html +module Fable.Python.Heapq + +open Fable.Core + +// fsharplint:disable MemberNames + +[] +type IExports = + /// Push the value item onto the heap, maintaining the heap invariant + /// See https://docs.python.org/3/library/heapq.html#heapq.heappush + abstract heappush: heap: ResizeArray<'T> * item: 'T -> unit + /// Pop and return the smallest item from the heap, maintaining the heap invariant + /// See https://docs.python.org/3/library/heapq.html#heapq.heappop + abstract heappop: heap: ResizeArray<'T> -> 'T + /// Push item on the heap, then pop and return the smallest item from the heap + /// See https://docs.python.org/3/library/heapq.html#heapq.heappushpop + abstract heappushpop: heap: ResizeArray<'T> * item: 'T -> 'T + /// Transform list into a heap, in-place, in linear time + /// See https://docs.python.org/3/library/heapq.html#heapq.heapify + abstract heapify: x: ResizeArray<'T> -> unit + /// Pop and return the smallest item from the heap, and also push the new item + /// See https://docs.python.org/3/library/heapq.html#heapq.heapreplace + abstract heapreplace: heap: ResizeArray<'T> * item: 'T -> 'T + /// Return a list with the n largest elements from the dataset + /// See https://docs.python.org/3/library/heapq.html#heapq.nlargest + abstract nlargest: n: int * iterable: 'T seq -> ResizeArray<'T> + /// Return a list with the n smallest elements from the dataset + /// See https://docs.python.org/3/library/heapq.html#heapq.nsmallest + abstract nsmallest: n: int * iterable: 'T seq -> ResizeArray<'T> + +/// Heap queue algorithm +[] +let heapq: IExports = nativeOnly diff --git a/src/stdlib/Queue.fs b/src/stdlib/Queue.fs index 31ec38c..ca69760 100644 --- a/src/stdlib/Queue.fs +++ b/src/stdlib/Queue.fs @@ -7,15 +7,15 @@ open Fable.Core [] type Queue<'T>() = - /// Return the approximate size of the queue. Note, qsize() > 0 doesn’t guarantee that a subsequent get() will not + /// Return the approximate size of the queue. Note, qsize() > 0 doesn't guarantee that a subsequent get() will not /// block, nor will qsize() < maxsize guarantee that put() will not block. member x.qsize() : int = nativeOnly - /// Return True if the queue is empty, False otherwise. If empty() returns True it doesn’t guarantee that a - /// subsequent call to put() will not block. Similarly, if empty() returns False it doesn’t guarantee that a + /// Return True if the queue is empty, False otherwise. If empty() returns True it doesn't guarantee that a + /// subsequent call to put() will not block. Similarly, if empty() returns False it doesn't guarantee that a /// subsequent call to get() will not block. member x.empty() : bool = nativeOnly - /// Return True if the queue is full, False otherwise. If full() returns True it doesn’t guarantee that a subsequent - /// call to get() will not block. Similarly, if full() returns False it doesn’t guarantee that a subsequent call to + /// Return True if the queue is full, False otherwise. If full() returns True it doesn't guarantee that a subsequent + /// call to get() will not block. Similarly, if full() returns False it doesn't guarantee that a subsequent call to /// put() will not block. member x.full() : bool = nativeOnly /// Put item into the queue. If optional args block is true and timeout is None (the default), block if necessary @@ -34,6 +34,10 @@ type Queue<'T>() = /// operation goes into an uninterruptible wait on an underlying lock. This means that no exceptions can occur, and /// in particular a SIGINT will not trigger a KeyboardInterrupt. member x.get(?block: bool, ?timeout: float) : 'T = nativeOnly + /// Equivalent to get(False). + /// See https://docs.python.org/3/library/queue.html#queue.Queue.get_nowait + [] + member x.get_nowait() : 'T = nativeOnly /// Blocks until all items in the queue have been gotten and processed. /// /// The count of unfinished tasks goes up whenever an item is added to the queue. The count goes down whenever a @@ -57,24 +61,31 @@ type LifoQueue<'T>() = [] type SimpleQueue<'T>() = - /// Return the approximate size of the queue. Note, qsize() > 0 doesn’t guarantee that a subsequent get() will not + /// Return the approximate size of the queue. Note, qsize() > 0 doesn't guarantee that a subsequent get() will not /// block, nor will qsize() < maxsize guarantee that put() will not block. member x.qsize() : int = nativeOnly - /// Return True if the queue is empty, False otherwise. If empty() returns True it doesn’t guarantee that a - /// subsequent call to put() will not block. Similarly, if empty() returns False it doesn’t guarantee that a + /// Return True if the queue is empty, False otherwise. If empty() returns True it doesn't guarantee that a + /// subsequent call to put() will not block. Similarly, if empty() returns False it doesn't guarantee that a /// subsequent call to get() will not block. member x.empty() : bool = nativeOnly - /// Return True if the queue is full, False otherwise. If full() returns True it doesn’t guarantee that a subsequent - /// call to get() will not block. Similarly, if full() returns False it doesn’t guarantee that a subsequent call to + /// Return True if the queue is full, False otherwise. If full() returns True it doesn't guarantee that a subsequent + /// call to get() will not block. Similarly, if full() returns False it doesn't guarantee that a subsequent call to /// put() will not block. member x.put(item: 'T, ?block: bool, ?timeout: float) : unit = nativeOnly - /// Remove and return an item from the queue. If optional args block is true and timeout is None (the default), - /// block if necessary until an item is available. If timeout is a positive number, it blocks at most timeout - /// seconds and raises the Empty exception if no item was available within that time. Otherwise (block is false), - /// return an item if one is immediately available, else raise the Empty exception (timeout is ignored in that - /// case). - /// - /// Prior to 3.0 on POSIX systems, and for all versions on Windows, if block is true and timeout is None, this - /// operation goes into an uninterruptible wait on an underlying lock. This means that no exceptions can occur, and - /// in particular a SIGINT will not trigger a KeyboardInterrupt. + /// Remove and return an item from the queue. member x.get(?block: bool, ?timeout: float) : 'T = nativeOnly + /// Equivalent to get(False). + [] + member x.get_nowait() : 'T = nativeOnly + +/// Exception raised when non-blocking get() is called on an empty Queue +/// See https://docs.python.org/3/library/queue.html#queue.Empty +[] +type Empty() = + inherit exn() + +/// Exception raised when non-blocking put() is called on a full Queue +/// See https://docs.python.org/3/library/queue.html#queue.Full +[] +type Full() = + inherit exn() diff --git a/src/stdlib/Threading.fs b/src/stdlib/Threading.fs new file mode 100644 index 0000000..fee6681 --- /dev/null +++ b/src/stdlib/Threading.fs @@ -0,0 +1,80 @@ +/// Type bindings for Python threading module: https://docs.python.org/3/library/threading.html +module Fable.Python.Threading + +open Fable.Core + +// fsharplint:disable MemberNames + +[] +type IExports = + /// Return the 'thread identifier' of the current thread + /// See https://docs.python.org/3/library/threading.html#threading.get_ident + abstract get_ident: unit -> int + /// Return the main Thread object + /// See https://docs.python.org/3/library/threading.html#threading.main_thread + abstract main_thread: unit -> Thread + /// Return the current Thread object + /// See https://docs.python.org/3/library/threading.html#threading.current_thread + abstract current_thread: unit -> Thread + /// Return the number of Thread objects currently alive + /// See https://docs.python.org/3/library/threading.html#threading.active_count + abstract active_count: unit -> int + /// Return a list of all Thread objects currently active + /// See https://docs.python.org/3/library/threading.html#threading.enumerate + abstract enumerate: unit -> Thread list + /// Return a new thread-local data object + /// See https://docs.python.org/3/library/threading.html#threading.local + abstract local: unit -> obj + +/// A thread of execution +/// See https://docs.python.org/3/library/threading.html#threading.Thread +and [] Thread(?target: unit -> unit, ?name: string, ?daemon: bool) = + /// Start the thread's activity + member _.start() : unit = nativeOnly + /// Wait until the thread terminates + member _.join(?timeout: float) : unit = nativeOnly + /// A boolean value indicating whether this thread is a daemon thread + member _.daemon: bool = nativeOnly + /// The thread's name + member _.name: string = nativeOnly + /// The 'thread identifier' of this thread + member _.ident: int option = nativeOnly + /// Whether the thread is alive + member _.is_alive() : bool = nativeOnly + +/// A lock object (mutual exclusion) +/// See https://docs.python.org/3/library/threading.html#threading.Lock +[] +type Lock() = + /// Acquire the lock + member _.acquire(?blocking: bool, ?timeout: float) : bool = nativeOnly + /// Release the lock + member _.release() : unit = nativeOnly + /// Return whether the lock is locked + member _.locked() : bool = nativeOnly + +/// A reentrant lock object +/// See https://docs.python.org/3/library/threading.html#threading.RLock +[] +type RLock() = + /// Acquire the lock + member _.acquire(?blocking: bool, ?timeout: float) : bool = nativeOnly + /// Release the lock + member _.release() : unit = nativeOnly + +/// An event object for thread synchronization +/// See https://docs.python.org/3/library/threading.html#threading.Event +[] +type Event() = + /// Set the internal flag to true + member _.set() : unit = nativeOnly + /// Reset the internal flag to false + member _.clear() : unit = nativeOnly + /// Return true if and only if the internal flag is true + member _.is_set() : bool = nativeOnly + /// Block until the internal flag is true or timeout + member _.wait(?timeout: float) : bool = nativeOnly + +/// Threading module access and utilities +[] +let threading: IExports = nativeOnly diff --git a/src/stdlib/Traceback.fs b/src/stdlib/Traceback.fs new file mode 100644 index 0000000..f4c4c7d --- /dev/null +++ b/src/stdlib/Traceback.fs @@ -0,0 +1,25 @@ +/// Type bindings for Python traceback module: https://docs.python.org/3/library/traceback.html +module Fable.Python.Traceback + +open Fable.Core + +// fsharplint:disable MemberNames + +[] +type IExports = + /// Print exception information and stack trace entries + /// See https://docs.python.org/3/library/traceback.html#traceback.print_exc + abstract print_exc: unit -> unit + /// Format exception information and stack trace entries as a string + /// See https://docs.python.org/3/library/traceback.html#traceback.format_exc + abstract format_exc: unit -> string + /// Print stack trace entries + /// See https://docs.python.org/3/library/traceback.html#traceback.print_stack + abstract print_stack: unit -> unit + /// Format stack trace entries as a list of strings + /// See https://docs.python.org/3/library/traceback.html#traceback.format_stack + abstract format_stack: unit -> ResizeArray + +/// Extract, format and print exceptions and their tracebacks +[] +let traceback: IExports = nativeOnly diff --git a/test/Fable.Python.Test.fsproj b/test/Fable.Python.Test.fsproj index 539890b..188b590 100644 --- a/test/Fable.Python.Test.fsproj +++ b/test/Fable.Python.Test.fsproj @@ -15,6 +15,11 @@ + + + + + diff --git a/test/TestBuiltinsAttr.fs b/test/TestBuiltinsAttr.fs new file mode 100644 index 0000000..67b351b --- /dev/null +++ b/test/TestBuiltinsAttr.fs @@ -0,0 +1,27 @@ +module Fable.Python.Tests.BuiltinsAttr + +open Fable.Python.Testing +open Fable.Python.Builtins +open Fable.Python.Threading + +[] +let ``test hasattr works`` () = + let local = threading.local () + setattr local "name" "test" + builtins.hasattr (local, "name") |> equal true + +[] +let ``test getattr with default works`` () = + let local = threading.local () + builtins.getattr (local, "missing", "default") |> equal "default" + +[] +let ``test setattr and getattr work`` () = + let local = threading.local () + builtins.setattr (local, "x", 42) + builtins.getattr (local, "x", 0) |> equal 42 + +[] +let ``test hasattr returns false for missing attribute`` () = + let local = threading.local () + builtins.hasattr (local, "nonexistent") |> equal false diff --git a/test/TestHeapq.fs b/test/TestHeapq.fs new file mode 100644 index 0000000..6001186 --- /dev/null +++ b/test/TestHeapq.fs @@ -0,0 +1,39 @@ +module Fable.Python.Tests.Heapq + +open Fable.Python.Testing +open Fable.Python.Heapq + +[] +let ``test heappush and heappop work`` () = + let heap = ResizeArray() + heapq.heappush (heap, 3) + heapq.heappush (heap, 1) + heapq.heappush (heap, 2) + heapq.heappop heap |> equal 1 + heapq.heappop heap |> equal 2 + heapq.heappop heap |> equal 3 + +[] +let ``test heapify works`` () = + let heap = ResizeArray [5; 3; 1; 4; 2] + heapq.heapify heap + heapq.heappop heap |> equal 1 + +[] +let ``test heappushpop works`` () = + let heap = ResizeArray [2; 4; 6] + heapq.heapify heap + // Push 1, then pop smallest (1) + heapq.heappushpop (heap, 1) |> equal 1 + // Push 3, then pop smallest (2) + heapq.heappushpop (heap, 3) |> equal 2 + +[] +let ``test nlargest works`` () = + let result = heapq.nlargest (3, [1; 5; 2; 8; 3; 7]) + result |> equal (ResizeArray [8; 7; 5]) + +[] +let ``test nsmallest works`` () = + let result = heapq.nsmallest (3, [1; 5; 2; 8; 3; 7]) + result |> equal (ResizeArray [1; 2; 3]) diff --git a/test/TestQueue.fs b/test/TestQueue.fs new file mode 100644 index 0000000..13ddf31 --- /dev/null +++ b/test/TestQueue.fs @@ -0,0 +1,57 @@ +module Fable.Python.Tests.Queue + +open Fable.Python.Testing +open Fable.Python.Queue + +[] +let ``test Queue put and get work`` () = + let q = Queue() + q.put 42 + q.get () |> equal 42 + +[] +let ``test Queue empty works`` () = + let q = Queue() + q.empty () |> equal true + q.put 1 + q.empty () |> equal false + +[] +let ``test Queue qsize works`` () = + let q = Queue() + q.qsize () |> equal 0 + q.put 1 + q.put 2 + q.qsize () |> equal 2 + +[] +let ``test Queue get_nowait works`` () = + let q = Queue() + q.put "hello" + q.get_nowait () |> equal "hello" + +[] +let ``test Queue get_nowait raises Empty`` () = + let q = Queue() + let mutable caught = false + + try + q.get_nowait () |> ignore + with :? Empty -> + caught <- true + + caught |> equal true + +[] +let ``test SimpleQueue put and get work`` () = + let q = SimpleQueue() + q.put 10 + q.put 20 + q.get () |> equal 10 + q.get () |> equal 20 + +[] +let ``test SimpleQueue get_nowait works`` () = + let q = SimpleQueue() + q.put 99 + q.get_nowait () |> equal 99 diff --git a/test/TestThreading.fs b/test/TestThreading.fs new file mode 100644 index 0000000..80d14ca --- /dev/null +++ b/test/TestThreading.fs @@ -0,0 +1,20 @@ +module Fable.Python.Tests.Threading + +open Fable.Python.Testing +open Fable.Python.Threading +open Fable.Python.Builtins + +[] +let ``test get_ident returns nonzero`` () = + let ident = threading.get_ident () + (ident <> 0) |> equal true + +[] +let ``test active_count is at least 1`` () = + (threading.active_count () >= 1) |> equal true + +[] +let ``test local creates thread-local storage`` () = + let local = threading.local () + setattr local "value" 42 + getattr local "value" 0 |> equal 42 diff --git a/test/TestTraceback.fs b/test/TestTraceback.fs new file mode 100644 index 0000000..8d08010 --- /dev/null +++ b/test/TestTraceback.fs @@ -0,0 +1,15 @@ +module Fable.Python.Tests.Traceback + +open Fable.Python.Testing +open Fable.Python.Traceback + +[] +let ``test format_exc returns string`` () = + // When no exception is active, format_exc returns "NoneType: None\n" + let result = traceback.format_exc () + (result.Length > 0) |> equal true + +[] +let ``test format_stack returns list`` () = + let result = traceback.format_stack () + (result.Count > 0) |> equal true