Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom exception #115

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions crates/jiter-python/jiter.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def from_json(
partial_mode: Literal[True, False, "off", "on", "trailing-strings"] = False,
catch_duplicate_keys: bool = False,
lossless_floats: bool = False,
error_in_path: bool = False,
) -> Any:
"""
Parse input bytes into a JSON object.
Expand All @@ -28,6 +29,7 @@ def from_json(
- 'trailing-strings' - allow incomplete JSON, and include the last incomplete string in the output
catch_duplicate_keys: if True, raise an exception if objects contain the same key multiple times
lossless_floats: if True, preserve full detail on floats using `LosslessFloat`
error_in_path: Whether to include the JSON path to the invalid JSON in `JsonParseError`

Returns:
Python object built from the JSON input.
Expand Down Expand Up @@ -63,8 +65,38 @@ class LosslessFloat:
def __bytes__(self) -> bytes:
"""Return the JSON bytes slice as bytes"""

def __str__(self):
def __str__(self) -> str:
"""Return the JSON bytes slice as a string"""

def __repr__(self):
def __repr__(self) -> str:
...


class JsonParseError(ValueError):
"""
Represents details of failed JSON parsing.
"""

def kind(self) -> str:
...

def description(self) -> str:
...

def path(self) -> list[str | int]:
...

def index(self) -> int:
...

def line(self) -> int:
...

def column(self) -> int:
...

def __str__(self) -> str:
"""String summary of the error, combined description and position"""

def __repr__(self) -> str:
...
11 changes: 7 additions & 4 deletions crates/jiter-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ use std::sync::OnceLock;

use pyo3::prelude::*;

use jiter::{map_json_error, LosslessFloat, PartialMode, PythonParse, StringCacheMode};
use jiter::{JsonParseError, LosslessFloat, PartialMode, PythonParse, StringCacheMode};

#[allow(clippy::fn_params_excessive_bools)]
#[allow(clippy::too_many_arguments)]
#[pyfunction(
signature = (
json_data,
Expand All @@ -15,6 +16,7 @@ use jiter::{map_json_error, LosslessFloat, PartialMode, PythonParse, StringCache
partial_mode=PartialMode::Off,
catch_duplicate_keys=false,
lossless_floats=false,
error_in_path=false,
)
)]
pub fn from_json<'py>(
Expand All @@ -25,17 +27,17 @@ pub fn from_json<'py>(
partial_mode: PartialMode,
catch_duplicate_keys: bool,
lossless_floats: bool,
error_in_path: bool,
) -> PyResult<Bound<'py, PyAny>> {
let parse_builder = PythonParse {
allow_inf_nan,
cache_mode,
partial_mode,
catch_duplicate_keys,
lossless_floats,
error_in_path,
};
parse_builder
.python_parse(py, json_data)
.map_err(|e| map_json_error(json_data, &e))
parse_builder.python_parse_exc(py, json_data)
}

pub fn get_jiter_version() -> &'static str {
Expand Down Expand Up @@ -70,5 +72,6 @@ fn jiter_python(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(cache_clear, m)?)?;
m.add_function(wrap_pyfunction!(cache_usage, m)?)?;
m.add_class::<LosslessFloat>()?;
m.add_class::<JsonParseError>()?;
Ok(())
}
57 changes: 47 additions & 10 deletions crates/jiter-python/tests/test_jiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from dirty_equals import IsFloatNan


def test_python_parse_numeric():
def test_parse_numeric():
parsed = jiter.from_json(
b' { "int": 1, "bigint": 123456789012345678901234567890, "float": 1.2} '
)
assert parsed == {"int": 1, "bigint": 123456789012345678901234567890, "float": 1.2}


def test_python_parse_other_cached():
def test_parse_other_cached():
parsed = jiter.from_json(
b'["string", true, false, null, NaN, Infinity, -Infinity]',
allow_inf_nan=True,
Expand All @@ -23,27 +23,64 @@ def test_python_parse_other_cached():
assert parsed == ["string", True, False, None, IsFloatNan(), inf, -inf]


def test_python_parse_other_no_cache():
def test_parse_other_no_cache():
parsed = jiter.from_json(
b'["string", true, false, null]',
cache_mode=False,
)
assert parsed == ["string", True, False, None]


def test_python_disallow_nan():
with pytest.raises(ValueError, match="expected value at line 1 column 2"):
def test_disallow_nan():
with pytest.raises(jiter.JsonParseError, match="expected value at line 1 column 2"):
jiter.from_json(b"[NaN]", allow_inf_nan=False)


def test_error():
with pytest.raises(ValueError, match="EOF while parsing a list at line 1 column 9"):
with pytest.raises(jiter.JsonParseError, match="EOF while parsing a list at line 1 column 9") as exc_info:
jiter.from_json(b'["string"')

assert exc_info.value.kind() == 'EofWhileParsingList'
assert exc_info.value.description() == 'EOF while parsing a list'
assert exc_info.value.path() == []
assert exc_info.value.index() == 9
assert exc_info.value.line() == 1
assert exc_info.value.column() == 9
assert repr(exc_info.value) == 'JsonParseError("EOF while parsing a list at line 1 column 9")'


def test_error_path():
with pytest.raises(jiter.JsonParseError, match="EOF while parsing a string at line 1 column 5") as exc_info:
jiter.from_json(b'["str', error_in_path=True)

assert exc_info.value.kind() == 'EofWhileParsingString'
assert exc_info.value.description() == 'EOF while parsing a string'
assert exc_info.value.path() == [0]
assert exc_info.value.index() == 5
assert exc_info.value.line() == 1


def test_error_path_empty():
with pytest.raises(jiter.JsonParseError) as exc_info:
jiter.from_json(b'"foo', error_in_path=True)

assert exc_info.value.kind() == 'EofWhileParsingString'
assert exc_info.value.path() == []


def test_error_path_object():
with pytest.raises(jiter.JsonParseError) as exc_info:
jiter.from_json(b'{"foo":\n[1,\n2, x', error_in_path=True)

assert exc_info.value.kind() == 'ExpectedSomeValue'
assert exc_info.value.index() == 15
assert exc_info.value.line() == 3
assert exc_info.value.path() == ['foo', 2]


def test_recursion_limit():
with pytest.raises(
ValueError, match="recursion limit exceeded at line 1 column 202"
jiter.JsonParseError, match="recursion limit exceeded at line 1 column 202"
):
jiter.from_json(b"[" * 10_000)

Expand Down Expand Up @@ -150,21 +187,21 @@ def test_partial_nested():
assert isinstance(parsed, dict)


def test_python_cache_usage_all():
def test_cache_usage_all():
jiter.cache_clear()
parsed = jiter.from_json(b'{"foo": "bar", "spam": 3}', cache_mode="all")
assert parsed == {"foo": "bar", "spam": 3}
assert jiter.cache_usage() == 3


def test_python_cache_usage_keys():
def test_cache_usage_keys():
jiter.cache_clear()
parsed = jiter.from_json(b'{"foo": "bar", "spam": 3}', cache_mode="keys")
assert parsed == {"foo": "bar", "spam": 3}
assert jiter.cache_usage() == 2


def test_python_cache_usage_none():
def test_cache_usage_none():
jiter.cache_clear()
parsed = jiter.from_json(
b'{"foo": "bar", "spam": 3}',
Expand Down
29 changes: 29 additions & 0 deletions crates/jiter/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@
}
}

impl JsonErrorType {
pub fn kind(&self) -> &'static str {
match self {
Self::FloatExpectingInt => "FloatExpectingInt",
Self::DuplicateKey(_) => "DuplicateKey",

Check warning on line 114 in crates/jiter/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

crates/jiter/src/errors.rs#L113-L114

Added lines #L113 - L114 were not covered by tests
Self::EofWhileParsingList => "EofWhileParsingList",
Self::EofWhileParsingObject => "EofWhileParsingObject",

Check warning on line 116 in crates/jiter/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

crates/jiter/src/errors.rs#L116

Added line #L116 was not covered by tests
Self::EofWhileParsingString => "EofWhileParsingString",
Self::EofWhileParsingValue => "EofWhileParsingValue",
Self::ExpectedColon => "ExpectedColon",
Self::ExpectedListCommaOrEnd => "ExpectedListCommaOrEnd",
Self::ExpectedObjectCommaOrEnd => "ExpectedObjectCommaOrEnd",
Self::ExpectedSomeIdent => "ExpectedSomeIdent",

Check warning on line 122 in crates/jiter/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

crates/jiter/src/errors.rs#L118-L122

Added lines #L118 - L122 were not covered by tests
Self::ExpectedSomeValue => "ExpectedSomeValue",
Self::InvalidEscape => "InvalidEscape",
Self::InvalidNumber => "InvalidNumber",
Self::NumberOutOfRange => "NumberOutOfRange",
Self::InvalidUnicodeCodePoint => "InvalidUnicodeCodePoint",
Self::ControlCharacterWhileParsingString => "ControlCharacterWhileParsingString",
Self::KeyMustBeAString => "KeyMustBeAString",
Self::LoneLeadingSurrogateInHexEscape => "LoneLeadingSurrogateInHexEscape",
Self::TrailingComma => "TrailingComma",
Self::TrailingCharacters => "TrailingCharacters",
Self::UnexpectedEndOfHexEscape => "UnexpectedEndOfHexEscape",
Self::RecursionLimitExceeded => "RecursionLimitExceeded",

Check warning on line 134 in crates/jiter/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

crates/jiter/src/errors.rs#L124-L134

Added lines #L124 - L134 were not covered by tests
}
}
}

pub type JsonResult<T> = Result<T, JsonError>;

/// Represents an error from parsing JSON
Expand Down
6 changes: 5 additions & 1 deletion crates/jiter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ mod lazy_index_map;
mod number_decoder;
mod parse;
#[cfg(feature = "python")]
mod py_error;
#[cfg(feature = "python")]
mod py_lossless_float;
#[cfg(feature = "python")]
mod py_string_cache;
Expand All @@ -23,9 +25,11 @@ pub use number_decoder::{NumberAny, NumberInt};
pub use parse::Peek;
pub use value::{JsonArray, JsonObject, JsonValue};

#[cfg(feature = "python")]
pub use py_error::JsonParseError;
#[cfg(feature = "python")]
pub use py_lossless_float::LosslessFloat;
#[cfg(feature = "python")]
pub use py_string_cache::{cache_clear, cache_usage, cached_py_string, pystring_fast_new, StringCacheMode};
#[cfg(feature = "python")]
pub use python::{map_json_error, PartialMode, PythonParse};
pub use python::{PartialMode, PythonParse};
Loading
Loading