Skip to content

Commit b6f55fa

Browse files
committed
oop
1 parent 1b2720a commit b6f55fa

File tree

8 files changed

+558
-44
lines changed

8 files changed

+558
-44
lines changed

Cargo.lock

Lines changed: 342 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ name = "exacting"
99
crate-type = ["cdylib"]
1010

1111
[dependencies]
12-
arcstr = "1.2.0"
13-
crossbeam = "0.8.4"
14-
pyo3 = "0.25.0"
12+
anyhow = "1.0.98"
13+
ijson = "0.1.4"
14+
pyo3 = { version = "0.25.0", features = ["anyhow"] }
15+
serde = "1.0.219"
16+
serde_json = "1.0.140"
17+
serde_json5 = "0.2.1"

python/exacting/core.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from typing import TYPE_CHECKING, Callable, List, Type, TypeVar
2-
from typing_extensions import dataclass_transform
2+
from typing_extensions import Self, dataclass_transform
33

44
import dataclasses
55
from dataclasses import asdict, dataclass, is_dataclass
66

77
from .dc import get_etypes_for_dc
8+
from .exacting import json as ejson # type: ignore
89

910
T = TypeVar("T", bound=Type)
1011

@@ -36,7 +37,7 @@ def init(self, *args, **kwargs):
3637

3738
if covered != len(etypes):
3839
raise ValueError(
39-
f"(dataclass {dc.__name__!r}) Expected to cover {len(etypes)} parameters, but got {covered}"
40+
f"(dataclass {dc.__name__!r}) Expected to cover {len(etypes)} parameter(s), but got {covered}"
4041
)
4142

4243
if args:
@@ -130,10 +131,55 @@ def __init_subclass__(cls) -> None:
130131
setattr(cls, "__init__", get_exact_init(dataclass(cls)))
131132

132133
def exact_as_dict(self):
133-
"""Creates a dictionary representation of this dataclass instance.
134+
"""(exacting) Creates a dictionary representation of this dataclass instance.
134135
135136
Raises:
136137
AssertionError: Expected a dataclass
137138
"""
138139
assert is_dataclass(self)
139140
return asdict(self)
141+
142+
@classmethod
143+
def exact_from_json(cls, raw: str, *, strict: bool = True) -> Self:
144+
"""(exacting) Initialize this dataclass model from JSON.
145+
146+
When `strict` is set to `False`, exacting uses JSON5, allowing comments,
147+
trailing commas, object keys without quotes, single quoted strings and more.
148+
149+
Example:
150+
151+
```python
152+
class Person(Exact):
153+
name: str
154+
age: int
155+
156+
# strict mode (default)
157+
Person.exact_from_json(\"\"\"
158+
{
159+
"name": "Harry",
160+
"age": 23
161+
}
162+
\"\"\")
163+
164+
# lenient :)
165+
Person.exact_from_json(\"\"\"
166+
{
167+
/*
168+
hell yeah!
169+
*/
170+
name: "Walter",
171+
age: 23, // <- trailing commas? yeah!
172+
}
173+
\"\"\", strict=False)
174+
```
175+
176+
Args:
177+
raw (str): The raw JSON.
178+
strict (bool): Whether to use strict mode.
179+
"""
180+
if strict:
181+
data = ejson.json_to_py(raw)
182+
else:
183+
data = ejson.jsonc_to_py(raw)
184+
185+
return cls(**data)

python/exacting/exacting.pyi

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import json as json

python/exacting/exacting/json.pyi

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from typing import Any
2+
3+
4+
def json_to_py(json: str) -> Any:
5+
"""Convert raw JSON to Python data types.
6+
7+
Args:
8+
json (str): The JSON data.
9+
"""
10+
11+
12+
def jsonc_to_py(json: str) -> Any:
13+
"""Convert raw JSON to Python data bytes while allowing comments,
14+
trailing commas, object keys without quotes, single quoted strings and more.
15+
16+
Uses JSON5:
17+
> JSON5 is a superset of JSON with an expanded syntax including some productions from ECMAScript 5.1.
18+
19+
Args:
20+
json (str): The JSONC data.
21+
"""

src/lib.rs

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,121 @@
1-
use pyo3::prelude::*;
1+
use pyo3::{
2+
exceptions,
3+
prelude::*,
4+
types::{ PyBool, PyDict, PyFloat, PyInt, PyList, PyNone, PyString },
5+
};
6+
7+
use ijson::{ IValue, ValueType };
8+
9+
#[pyfunction]
10+
fn json_to_py(py: Python, json: &str) -> PyResult<Py<PyAny>> {
11+
let value = match serde_json::from_str::<IValue>(json) {
12+
Ok(d) => d,
13+
Err(e) => {
14+
return Err(
15+
exceptions::PyRuntimeError::new_err(format!("Failed to load JSON:\n{:#?}", e))
16+
);
17+
}
18+
};
19+
ivalue_to_py(py, value)
20+
}
21+
22+
#[pyfunction]
23+
fn jsonc_to_py(py: Python, json: &str) -> PyResult<Py<PyAny>> {
24+
let value = match serde_json5::from_str::<IValue>(json) {
25+
Ok(d) => d,
26+
Err(e) => {
27+
return Err(
28+
exceptions::PyRuntimeError::new_err(format!("Failed to load JSONC:\n{:#?}", e))
29+
);
30+
}
31+
};
32+
ivalue_to_py(py, value)
33+
}
34+
35+
fn ivalue_to_py(py: Python, value: IValue) -> PyResult<Py<PyAny>> {
36+
match value.type_() {
37+
ValueType::Array => {
38+
let list = PyList::empty(py);
39+
let Ok(array) = value.into_array() else {
40+
return Err(exceptions::PyRuntimeError::new_err("Failed to convert into array"));
41+
};
42+
43+
for item in array {
44+
let value = ivalue_to_py(py, item)?;
45+
list.append(value)?;
46+
}
47+
48+
Ok(list.unbind().into())
49+
}
50+
ValueType::Bool => {
51+
let b = PyBool::new(py, value.to_bool().unwrap());
52+
Ok(unsafe { Py::from_borrowed_ptr_or_opt(py, b.as_ptr()).unwrap() })
53+
}
54+
ValueType::Null => {
55+
let none = PyNone::get(py);
56+
Ok(unsafe { Py::from_borrowed_ptr_or_opt(py, none.as_ptr()).unwrap() })
57+
}
58+
ValueType::Number => {
59+
let Ok(number) = value.into_number() else {
60+
return Err(exceptions::PyRuntimeError::new_err("Failed to convert into number"));
61+
};
62+
63+
if number.has_decimal_point() {
64+
Ok(PyFloat::new(py, number.to_f64().unwrap()).unbind().into())
65+
} else {
66+
Ok(PyInt::new(py, number.to_i64().unwrap()).unbind().into())
67+
}
68+
}
69+
ValueType::Object => {
70+
let Ok(object) = value.into_object() else {
71+
return Err(exceptions::PyRuntimeError::new_err("Failed to convert into object"));
72+
};
73+
74+
let dict = PyDict::new(py);
75+
for (key, value) in object {
76+
dict.set_item(key.as_str(), ivalue_to_py(py, value)?)?;
77+
}
78+
79+
Ok(dict.unbind().into())
80+
}
81+
ValueType::String => {
82+
let Ok(s) = value.into_string() else {
83+
return Err(exceptions::PyRuntimeError::new_err("Failed to convert into string"));
84+
};
85+
Ok(PyString::new(py, s.as_str()).unbind().into())
86+
}
87+
}
88+
}
89+
90+
// #[derive(FromPyObject)]
91+
// enum AnyPy {
92+
// List(Py<PyList>),
93+
// Dict(Py<PyDict>),
94+
// Str(String),
95+
// Bool(bool),
96+
// None(Py<PyNone>),
97+
// Int(i64),
98+
// Float(f64),
99+
// }
2100

3101
#[pymodule]
4-
fn exacting(m: &Bound<'_, PyModule>) -> PyResult<()> {
5-
Ok(())
102+
mod exacting {
103+
use super::*;
104+
105+
#[pymodule]
106+
mod json {
107+
use super::*;
108+
109+
#[pymodule_init]
110+
fn init(m: &Bound<'_, PyModule>) -> PyResult<()> {
111+
m.add_function(wrap_pyfunction!(json_to_py, m)?)?;
112+
m.add_function(wrap_pyfunction!(jsonc_to_py, m)?)?;
113+
Ok(())
114+
}
115+
}
116+
117+
#[pymodule_init]
118+
fn init(_m: &Bound<'_, PyModule>) -> PyResult<()> {
119+
Ok(())
120+
}
6121
}

test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from exacting import Exact
2+
3+
4+
class Person(Exact):
5+
name: str
6+
age: int
7+
8+
9+
d = Person.exact_from_json(
10+
"""
11+
{
12+
/*
13+
hell yeah!
14+
*/
15+
name: "Walter",
16+
age: 23, // <- yeah, just do whatever!
17+
}
18+
""",
19+
strict=False,
20+
)
21+
print(d)

0 commit comments

Comments
 (0)