Skip to content

Commit 060e1c8

Browse files
authored
Preserve element order when initializing Python dictionary (pvieito#57)
* Change dictionary literal initializer implementation * Add test for dictionary initializer * Fix CI tests
1 parent 5105e31 commit 060e1c8

File tree

3 files changed

+72
-11
lines changed

3 files changed

+72
-11
lines changed

PythonKit/Python.swift

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,8 +1274,8 @@ extension PythonObject : SignedNumeric {
12741274
return self < 0 ? -self : self
12751275
}
12761276

1277-
//override the default implementation of - prefix function
1278-
//from SignedNumeric (https://bugs.swift.org/browse/SR-13293)
1277+
// Override the default implementation of `-` prefix function
1278+
// from SignedNumeric (https://bugs.swift.org/browse/SR-13293).
12791279
public static prefix func - (_ operand: Self) -> Self {
12801280
return performUnaryOp(PyNumber_Negative, operand: operand)
12811281
}
@@ -1472,8 +1472,36 @@ extension PythonObject : ExpressibleByArrayLiteral, ExpressibleByDictionaryLiter
14721472
}
14731473
public typealias Key = PythonObject
14741474
public typealias Value = PythonObject
1475+
1476+
// Preserves element order in the final Python object, unlike
1477+
// `Dictionary.pythonObject`. When keys are duplicated, throw the same
1478+
// runtime error as `Swift.Dictionary.init(dictionaryLiteral:)`. This
1479+
// differs from Python's key uniquing semantics, which silently override an
1480+
// existing key with the next one it encounters.
14751481
public init(dictionaryLiteral elements: (PythonObject, PythonObject)...) {
1476-
self.init(Dictionary(elements, uniquingKeysWith: { lhs, _ in lhs }))
1482+
_ = Python // Ensure Python is initialized.
1483+
let dict = PyDict_New()!
1484+
for (key, value) in elements {
1485+
let k = key.ownedPyObject
1486+
let v = value.ownedPyObject
1487+
1488+
// Use Python's native key checking instead of querying whether
1489+
// `elements` contains the key. Although this could theoretically
1490+
// produce different results, it produces the Python object we want.
1491+
switch PyDict_Contains(dict, k) {
1492+
case 0:
1493+
PyDict_SetItem(dict, k, v)
1494+
case 1:
1495+
fatalError("Dictionary literal contains duplicate keys")
1496+
default:
1497+
try! throwPythonErrorIfPresent()
1498+
fatalError("No result or error checking whether \(elements) contains \(key)")
1499+
}
1500+
1501+
Py_DecRef(k)
1502+
Py_DecRef(v)
1503+
}
1504+
self.init(consuming: dict)
14771505
}
14781506
}
14791507

@@ -1876,7 +1904,9 @@ public struct PythonClass {
18761904
(key, value.pythonObject)
18771905
}
18781906

1879-
dictionary = Dictionary(castedElements, uniquingKeysWith: { lhs, _ in lhs })
1907+
dictionary = Dictionary(castedElements, uniquingKeysWith: { _, _ in
1908+
fatalError("Dictionary literal contains duplicate keys")
1909+
})
18801910
}
18811911
}
18821912

PythonKit/PythonLibrary+Symbols.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ let PyErr_Fetch: @convention(c) (
9393
let PyDict_New: @convention(c) () -> PyObjectPointer? =
9494
PythonLibrary.loadSymbol(name: "PyDict_New")
9595

96+
let PyDict_Contains: @convention(c) (
97+
PyObjectPointer?, PyObjectPointer?) -> Int32 =
98+
PythonLibrary.loadSymbol(name: "PyDict_Contains")
99+
96100
let PyDict_SetItem: @convention(c) (
97101
PyObjectPointer?, PyObjectPointer, PyObjectPointer) -> Void =
98102
PythonLibrary.loadSymbol(name: "PyDict_SetItem")

Tests/PythonKitTests/PythonRuntimeTests.swift

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class PythonRuntimeTests: XCTestCase {
3434
XCTAssertEqual(2, Python.len(dict))
3535
XCTAssertEqual(1, dict["a"])
3636
XCTAssertEqual(0.5, dict[1])
37-
37+
3838
XCTAssertEqual(2, dict.count as Int)
3939
XCTAssertEqual(2, dict.checking.count!)
4040
XCTAssertEqual(2, dict.throwing.count!)
@@ -43,6 +43,33 @@ class PythonRuntimeTests: XCTestCase {
4343
XCTAssertEqual("c", dict["b"])
4444
dict["b"] = "d"
4545
XCTAssertEqual("d", dict["b"])
46+
47+
// Dictionary initializer patch does not work on Python 2, but that
48+
// version is no longer being actively supported.
49+
guard Python.versionInfo.major >= 3 else {
50+
return
51+
}
52+
53+
// Pandas DataFrame regression test spotted in Jupyter. This is
54+
// non-deterministic, so repeat it several times to ensure the bug does
55+
// not appear.
56+
for _ in 0..<15 {
57+
let records: [PythonObject] = [
58+
["col 1": 3, "col 2": 5],
59+
["col 1": 8, "col 2": 2]
60+
]
61+
let records_description =
62+
"[{'col 1': 3, 'col 2': 5}, {'col 1': 8, 'col 2': 2}]"
63+
XCTAssertEqual(String(describing: records), records_description)
64+
65+
let records_alt: [PythonObject] = [
66+
["col 1": 3, "col 2": 5, "col 3": 4],
67+
["col 1": 8, "col 2": 2, "col 3": 4]
68+
]
69+
let records_alt_description =
70+
"[{'col 1': 3, 'col 2': 5, 'col 3': 4}, {'col 1': 8, 'col 2': 2, 'col 3': 4}]"
71+
XCTAssertEqual(String(describing: records_alt), records_alt_description)
72+
}
4673
}
4774

4875
func testRange() {
@@ -123,12 +150,12 @@ class PythonRuntimeTests: XCTestCase {
123150
}
124151

125152
func testUnaryOps() {
126-
var x = PythonObject(5)
127-
x = -x
128-
XCTAssertEqual(-5, x)
129-
x = PythonObject(-5)
130-
x = -x
131-
XCTAssertEqual(5, x)
153+
var x = PythonObject(5)
154+
x = -x
155+
XCTAssertEqual(-5, x)
156+
x = PythonObject(-5)
157+
x = -x
158+
XCTAssertEqual(5, x)
132159
}
133160

134161
func testComparable() {

0 commit comments

Comments
 (0)