From cbc296a419fda831478e26ce99f4072886f1228c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 04:03:02 +0000
Subject: [PATCH] feat(stdlib): add collections module bindings (Counter,
defaultdict, deque, OrderedDict)
Adds F# bindings for the most commonly used classes in Python's collections
module:
- Counter<'T>: count hashable objects; supports most_common, elements,
total, update, subtract; static Counter.ofSeq factory
- defaultdict<'TKey, 'TValue>: dict with callable factory for missing keys;
includes contains helper to check key presence without invoking factory
- deque<'T>: O(1) double-ended queue with append/appendleft, pop/popleft,
rotate, and optional bounded maxlen; static ofSeq and withMaxlen factories
- OrderedDict<'TKey, 'TValue>: dict subclass with move_to_end and
order-sensitive popitem
Also adds 35 tests covering construction, mutation, and edge cases for all
four types.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/Fable.Python.fsproj | 1 +
src/stdlib/Collections.fs | 264 ++++++++++++++++++++++++++++++++++
test/Fable.Python.Test.fsproj | 1 +
test/TestCollections.fs | 231 +++++++++++++++++++++++++++++
4 files changed, 497 insertions(+)
create mode 100644 src/stdlib/Collections.fs
create mode 100644 test/TestCollections.fs
diff --git a/src/Fable.Python.fsproj b/src/Fable.Python.fsproj
index 3dff498..fc91a6f 100644
--- a/src/Fable.Python.fsproj
+++ b/src/Fable.Python.fsproj
@@ -23,6 +23,7 @@
+
diff --git a/src/stdlib/Collections.fs b/src/stdlib/Collections.fs
new file mode 100644
index 0000000..d5d61e6
--- /dev/null
+++ b/src/stdlib/Collections.fs
@@ -0,0 +1,264 @@
+/// Type bindings for Python collections module: https://docs.python.org/3/library/collections.html
+module Fable.Python.Collections
+
+open Fable.Core
+
+// fsharplint:disable MemberNames
+
+// ============================================================================
+// Counter
+// ============================================================================
+
+/// A dict subclass for counting hashable objects.
+/// Elements are stored as dictionary keys and their counts are stored as values.
+/// Counts are allowed to be any integer value including zero or negative counts.
+/// See https://docs.python.org/3/library/collections.html#collections.Counter
+[]
+type Counter<'T>() =
+ /// Get the count for key; missing keys return 0 (unlike a regular dict)
+ []
+ member _.Item(key: 'T) : int = nativeOnly
+
+ /// Return elements and their counts as key-value pairs
+ member _.items() : seq<'T * int> = nativeOnly
+
+ /// Return an iterator over elements, repeating each as many times as its count.
+ /// Elements with counts <= 0 are not included.
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.elements
+ member _.elements() : seq<'T> = nativeOnly
+
+ /// Return the n most common elements and their counts (most common first).
+ /// If n is omitted, return all elements in counter order.
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.most_common
+ member _.most_common() : seq<'T * int> = nativeOnly
+
+ /// Return the n most common elements and their counts (most common first).
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.most_common
+ []
+ member _.most_common(n: int) : seq<'T * int> = nativeOnly
+
+ /// Return the total of all counts (requires Python 3.10+).
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.total
+ member _.total() : int = nativeOnly
+
+ /// Add counts from the iterable or mapping; count becomes sum of old and new counts.
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.update
+ member _.update(iterable: 'T seq) : unit = nativeOnly
+
+ /// Subtract counts from the iterable or mapping; count becomes difference.
+ /// Counts can become negative.
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter.subtract
+ member _.subtract(iterable: 'T seq) : unit = nativeOnly
+
+ /// Return a Counter from a sequence of elements.
+ /// See https://docs.python.org/3/library/collections.html#collections.Counter
+ []
+ static member ofSeq(iterable: 'T seq) : Counter<'T> = nativeOnly
+
+// ============================================================================
+// defaultdict
+// ============================================================================
+
+/// A dict subclass that calls a factory to supply missing values.
+/// When a key is not found, the factory function (called with no arguments)
+/// is called to produce a new value, which is then stored and returned.
+/// If the factory is not set (None), missing keys raise KeyError as normal.
+/// See https://docs.python.org/3/library/collections.html#collections.defaultdict
+[]
+type defaultdict<'TKey, 'TValue>(defaultFactory: unit -> 'TValue) =
+ /// Get or set the value for key; missing keys invoke the factory
+ []
+ member _.Item(key: 'TKey) : 'TValue = nativeOnly
+
+ /// Set value for key
+ []
+ member _.set(key: 'TKey, value: 'TValue) : unit = nativeOnly
+
+ /// Return key-value pairs
+ member _.items() : seq<'TKey * 'TValue> = nativeOnly
+
+ /// Return keys
+ member _.keys() : seq<'TKey> = nativeOnly
+
+ /// Return values
+ member _.values() : seq<'TValue> = nativeOnly
+
+ /// Return value for key if present, otherwise None.
+ /// Does NOT invoke the factory.
+ member _.get(key: 'TKey) : 'TValue option = nativeOnly
+
+ /// Return value for key if present, otherwise defaultValue.
+ /// Does NOT invoke the factory.
+ []
+ member _.get(key: 'TKey, defaultValue: 'TValue) : 'TValue = nativeOnly
+
+ /// If key is in the dict, return its value.
+ /// If not, insert key with the factory's value and return that value.
+ member _.setdefault(key: 'TKey) : 'TValue = nativeOnly
+
+ /// Remove and return the value for key, or raise KeyError.
+ member _.pop(key: 'TKey) : 'TValue = nativeOnly
+
+ /// Remove and return the value for key, or return defaultValue.
+ []
+ member _.pop(key: 'TKey, defaultValue: 'TValue) : 'TValue = nativeOnly
+
+ /// Merge another dict into this one
+ member _.update(other: System.Collections.Generic.IDictionary<'TKey, 'TValue>) : unit = nativeOnly
+
+ /// Remove all items
+ member _.clear() : unit = nativeOnly
+
+ /// Return a shallow copy
+ member _.copy() : defaultdict<'TKey, 'TValue> = nativeOnly
+
+ /// Check if a key is present (does NOT invoke factory)
+ []
+ member _.contains(key: 'TKey) : bool = nativeOnly
+
+// ============================================================================
+// deque
+// ============================================================================
+
+/// A double-ended queue with O(1) appends and pops from either end.
+/// If maxlen is set, the deque is bounded to that maximum length; items are
+/// discarded from the opposite end when the bound is reached.
+/// See https://docs.python.org/3/library/collections.html#collections.deque
+[]
+type deque<'T>() =
+ /// Number of elements in the deque
+ []
+ member _.length() : int = nativeOnly
+
+ /// Get element at index
+ []
+ member _.Item(index: int) : 'T = nativeOnly
+
+ /// Maximum length of the deque, or None if unbounded
+ member _.maxlen : int option = nativeOnly
+
+ /// Add item to the right end
+ member _.append(item: 'T) : unit = nativeOnly
+
+ /// Add item to the left end
+ member _.appendleft(item: 'T) : unit = nativeOnly
+
+ /// Remove and return item from the right end
+ member _.pop() : 'T = nativeOnly
+
+ /// Remove and return item from the left end
+ member _.popleft() : 'T = nativeOnly
+
+ /// Extend the right side of the deque by appending elements from iterable
+ member _.extend(iterable: 'T seq) : unit = nativeOnly
+
+ /// Extend the left side of the deque by appending elements from iterable.
+ /// Note: each element is appended to the left, reversing the iterable order.
+ member _.extendleft(iterable: 'T seq) : unit = nativeOnly
+
+ /// Rotate the deque n steps to the right. If n is negative, rotate left.
+ member _.rotate(n: int) : unit = nativeOnly
+
+ /// Count the number of occurrences of value
+ []
+ member _.count(value: 'T) : int = nativeOnly
+
+ /// Return the position of value (raise ValueError if not found)
+ member _.index(value: 'T) : int = nativeOnly
+
+ /// Insert value before position i
+ member _.insert(i: int, value: 'T) : unit = nativeOnly
+
+ /// Remove the first occurrence of value (raise ValueError if not found)
+ member _.remove(value: 'T) : unit = nativeOnly
+
+ /// Reverse the deque in-place
+ member _.reverse() : unit = nativeOnly
+
+ /// Remove all elements
+ member _.clear() : unit = nativeOnly
+
+ /// Return a shallow copy
+ member _.copy() : deque<'T> = nativeOnly
+
+ /// Create a deque from a sequence
+ []
+ static member ofSeq(iterable: 'T seq) : deque<'T> = nativeOnly
+
+ /// Create a bounded deque from a sequence with maximum length
+ []
+ static member ofSeq(iterable: 'T seq, maxlen: int) : deque<'T> = nativeOnly
+
+ /// Create an empty bounded deque with maximum length
+ []
+ static member withMaxlen(maxlen: int) : deque<'T> = nativeOnly
+
+// ============================================================================
+// OrderedDict
+// ============================================================================
+
+/// A dict subclass that remembers insertion order. Since Python 3.7, all dicts
+/// maintain insertion order, but OrderedDict has a few extra features:
+/// `move_to_end` and order-sensitive equality.
+/// See https://docs.python.org/3/library/collections.html#collections.OrderedDict
+[]
+type OrderedDict<'TKey, 'TValue>() =
+ /// Get or set value for key
+ []
+ member _.Item(key: 'TKey) : 'TValue = nativeOnly
+
+ /// Set value for key
+ []
+ member _.set(key: 'TKey, value: 'TValue) : unit = nativeOnly
+
+ /// Return key-value pairs in insertion order
+ member _.items() : seq<'TKey * 'TValue> = nativeOnly
+
+ /// Return keys in insertion order
+ member _.keys() : seq<'TKey> = nativeOnly
+
+ /// Return values in insertion order
+ member _.values() : seq<'TValue> = nativeOnly
+
+ /// Get value for key, or None if missing
+ member _.get(key: 'TKey) : 'TValue option = nativeOnly
+
+ /// Get value for key, or defaultValue if missing
+ []
+ member _.get(key: 'TKey, defaultValue: 'TValue) : 'TValue = nativeOnly
+
+ /// Remove and return the value for key (or raise KeyError)
+ member _.pop(key: 'TKey) : 'TValue = nativeOnly
+
+ /// Remove and return the value for key, or return defaultValue
+ []
+ member _.pop(key: 'TKey, defaultValue: 'TValue) : 'TValue = nativeOnly
+
+ /// Move key to the end. If last is False, move to the beginning.
+ /// See https://docs.python.org/3/library/collections.html#collections.OrderedDict.move_to_end
+ member _.move_to_end(key: 'TKey) : unit = nativeOnly
+
+ /// Move key to the end (last=True) or beginning (last=False).
+ []
+ member _.move_to_end(key: 'TKey, last: bool) : unit = nativeOnly
+
+ /// Remove and return a (key, value) pair. last=True removes from the end.
+ /// See https://docs.python.org/3/library/collections.html#collections.OrderedDict.popitem
+ member _.popitem() : 'TKey * 'TValue = nativeOnly
+
+ /// Remove and return from end (last=True) or beginning (last=False).
+ []
+ member _.popitem(last: bool) : 'TKey * 'TValue = nativeOnly
+
+ /// Merge another dict into this one
+ member _.update(other: System.Collections.Generic.IDictionary<'TKey, 'TValue>) : unit = nativeOnly
+
+ /// Remove all items
+ member _.clear() : unit = nativeOnly
+
+ /// Return a shallow copy
+ member _.copy() : OrderedDict<'TKey, 'TValue> = nativeOnly
+
+ /// Check if key is present
+ []
+ member _.contains(key: 'TKey) : bool = nativeOnly
diff --git a/test/Fable.Python.Test.fsproj b/test/Fable.Python.Test.fsproj
index 441f8a7..69c1f74 100644
--- a/test/Fable.Python.Test.fsproj
+++ b/test/Fable.Python.Test.fsproj
@@ -17,6 +17,7 @@
+
diff --git a/test/TestCollections.fs b/test/TestCollections.fs
new file mode 100644
index 0000000..931e48c
--- /dev/null
+++ b/test/TestCollections.fs
@@ -0,0 +1,231 @@
+module Fable.Python.Tests.Collections
+
+open Fable.Python.Testing
+open Fable.Python.Collections
+
+// ============================================================================
+// Counter tests
+// ============================================================================
+
+[]
+let ``Counter: empty counter has zero count for missing key`` () =
+ let c = Counter()
+ c.Item("x") |> equal 0
+
+[]
+let ``Counter: ofSeq counts elements`` () =
+ let c = Counter.ofSeq [ "a"; "b"; "a"; "c"; "a"; "b" ]
+ c.Item("a") |> equal 3
+ c.Item("b") |> equal 2
+ c.Item("c") |> equal 1
+
+[]
+let ``Counter: missing key returns 0`` () =
+ let c = Counter.ofSeq [ "a"; "b" ]
+ c.Item("z") |> equal 0
+
+[]
+let ``Counter: most_common returns all elements sorted by count`` () =
+ let c = Counter.ofSeq [ "a"; "b"; "a"; "c"; "a"; "b" ]
+ let top = c.most_common() |> Seq.head
+ top |> equal ("a", 3)
+
+[]
+let ``Counter: most_common n returns top n elements`` () =
+ let c = Counter.ofSeq [ "a"; "b"; "a"; "c"; "a"; "b" ]
+ let topTwo = c.most_common(2) |> Seq.toList
+ topTwo |> List.length |> equal 2
+ topTwo |> List.head |> equal ("a", 3)
+
+[]
+let ``Counter: elements returns repeated sequence`` () =
+ let c = Counter.ofSeq [ "a"; "a"; "b" ]
+ let elems = c.elements() |> Seq.toList |> List.sort
+ elems |> equal [ "a"; "a"; "b" ]
+
+[]
+let ``Counter: total sums all counts`` () =
+ let c = Counter.ofSeq [ "a"; "b"; "a"; "c" ]
+ c.total() |> equal 4
+
+[]
+let ``Counter: update adds counts`` () =
+ let c = Counter.ofSeq [ "a"; "b" ]
+ c.update([ "a"; "c" ])
+ c.Item("a") |> equal 2
+ c.Item("c") |> equal 1
+
+[]
+let ``Counter: subtract reduces counts`` () =
+ let c = Counter.ofSeq [ "a"; "a"; "b" ]
+ c.subtract([ "a" ])
+ c.Item("a") |> equal 1
+
+// ============================================================================
+// defaultdict tests
+// ============================================================================
+
+[]
+let ``defaultdict: missing key invokes factory`` () =
+ let d = defaultdict>(fun () -> ResizeArray())
+ let list = d.Item("key")
+ list.Count |> equal 0
+
+[]
+let ``defaultdict: factory creates separate instances`` () =
+ let d = defaultdict>(fun () -> ResizeArray())
+ let list1 = d.Item("a")
+ list1.Add(1)
+ let list2 = d.Item("b")
+ list2.Count |> equal 0
+
+[]
+let ``defaultdict: int factory starts at zero`` () =
+ let d = defaultdict(fun () -> 0)
+ d.Item("key") |> equal 0
+
+[]
+let ``defaultdict: get returns None for missing key without invoking factory`` () =
+ let mutable factoryCalled = false
+ let d = defaultdict(fun () -> factoryCalled <- true; 0)
+ let result = d.get("missing")
+ result |> equal None
+ factoryCalled |> equal false
+
+[]
+let ``defaultdict: get with default returns default for missing key`` () =
+ let d = defaultdict(fun () -> 0)
+ d.get("missing", 42) |> equal 42
+
+[]
+let ``defaultdict: contains returns false for missing key`` () =
+ let d = defaultdict(fun () -> 0)
+ d.contains("key") |> equal false
+
+[]
+let ``defaultdict: contains returns true after access`` () =
+ let d = defaultdict(fun () -> 99)
+ let _ = d.Item("key")
+ d.contains("key") |> equal true
+
+// ============================================================================
+// deque tests
+// ============================================================================
+
+[]
+let ``deque: empty deque has length 0`` () =
+ let d = deque()
+ d.length() |> equal 0
+
+[]
+let ``deque: ofSeq creates deque from sequence`` () =
+ let d = deque.ofSeq [ 1; 2; 3 ]
+ d.length() |> equal 3
+
+[]
+let ``deque: append adds to right`` () =
+ let d = deque.ofSeq [ 1; 2 ]
+ d.append(3)
+ d.Item(2) |> equal 3
+
+[]
+let ``deque: appendleft adds to left`` () =
+ let d = deque.ofSeq [ 1; 2 ]
+ d.appendleft(0)
+ d.Item(0) |> equal 0
+ d.length() |> equal 3
+
+[]
+let ``deque: pop removes from right`` () =
+ let d = deque.ofSeq [ 1; 2; 3 ]
+ let v = d.pop()
+ v |> equal 3
+ d.length() |> equal 2
+
+[]
+let ``deque: popleft removes from left`` () =
+ let d = deque.ofSeq [ 1; 2; 3 ]
+ let v = d.popleft()
+ v |> equal 1
+ d.length() |> equal 2
+
+[]
+let ``deque: rotate shifts elements right`` () =
+ let d = deque.ofSeq [ 1; 2; 3; 4; 5 ]
+ d.rotate(2)
+ d.Item(0) |> equal 4
+ d.Item(1) |> equal 5
+
+[]
+let ``deque: maxlen is None for unbounded deque`` () =
+ let d = deque.ofSeq [ 1; 2; 3 ]
+ d.maxlen |> equal None
+
+[]
+let ``deque: withMaxlen creates bounded deque`` () =
+ let d = deque.withMaxlen(3)
+ d.append(1)
+ d.append(2)
+ d.append(3)
+ d.append(4) // should push out 1
+ d.length() |> equal 3
+ d.Item(0) |> equal 2
+
+[]
+let ``deque: ofSeq with maxlen creates bounded deque`` () =
+ let d = deque.ofSeq ([ 1; 2; 3; 4; 5 ], 3)
+ d.length() |> equal 3
+ d.maxlen |> equal (Some 3)
+
+[]
+let ``deque: count occurrences`` () =
+ let d = deque.ofSeq [ 1; 2; 1; 3; 1 ]
+ d.count(1) |> equal 3
+
+// ============================================================================
+// OrderedDict tests
+// ============================================================================
+
+[]
+let ``OrderedDict: preserves insertion order`` () =
+ let od = OrderedDict()
+ od.set("a", 1)
+ od.set("b", 2)
+ od.set("c", 3)
+ od.keys() |> Seq.toList |> equal [ "a"; "b"; "c" ]
+
+[]
+let ``OrderedDict: get existing key`` () =
+ let od = OrderedDict()
+ od.set("x", 42)
+ od.Item("x") |> equal 42
+
+[]
+let ``OrderedDict: get returns None for missing key`` () =
+ let od = OrderedDict()
+ od.get("missing") |> equal None
+
+[]
+let ``OrderedDict: move_to_end moves last element`` () =
+ let od = OrderedDict()
+ od.set("a", 1)
+ od.set("b", 2)
+ od.set("c", 3)
+ od.move_to_end("a")
+ od.keys() |> Seq.toList |> equal [ "b"; "c"; "a" ]
+
+[]
+let ``OrderedDict: move_to_end with last false moves to front`` () =
+ let od = OrderedDict()
+ od.set("a", 1)
+ od.set("b", 2)
+ od.set("c", 3)
+ od.move_to_end("c", false)
+ od.keys() |> Seq.toList |> equal [ "c"; "a"; "b" ]
+
+[]
+let ``OrderedDict: contains returns correct result`` () =
+ let od = OrderedDict()
+ od.set("a", 1)
+ od.contains("a") |> equal true
+ od.contains("b") |> equal false