Skip to content
Merged
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
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions THIRD-PARTY-LICENSES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2534,9 +2534,9 @@ limitations under the License.
** itoa; version 1.0.18 -- https://crates.io/crates/itoa
** libc; version 0.2.189 -- https://crates.io/crates/libc
** manyhow-macros; version 0.11.4 -- https://crates.io/crates/manyhow-macros
** openjd-expr; version 0.7.0 -- https://crates.io/crates/openjd-expr
** openjd-model; version 0.7.1 -- https://crates.io/crates/openjd-model
** openjd-sessions; version 0.5.8 -- https://crates.io/crates/openjd-sessions
** openjd-expr; version 0.8.0 -- https://crates.io/crates/openjd-expr
** openjd-model; version 0.8.0 -- https://crates.io/crates/openjd-model
** openjd-sessions; version 0.6.0 -- https://crates.io/crates/openjd-sessions
** pin-project-lite; version 0.2.17 -- https://crates.io/crates/pin-project-lite
** portable-atomic; version 1.15.0 -- https://crates.io/crates/portable-atomic
** proc-macro2; version 1.0.107 -- https://crates.io/crates/proc-macro2
Expand Down
6 changes: 3 additions & 3 deletions rust-bindings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ name = "_openjd_rs"
crate-type = ["cdylib", "rlib"]

[dependencies]
openjd-expr = "0.7.0"
openjd-model = "0.7.1"
openjd-sessions = "0.5.8"
openjd-expr = "0.8.0"
openjd-model = "0.8.0"
openjd-sessions = "0.6.0"
tokio = { version = "1", features = ["rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
serde_json = "1"
Expand Down
22 changes: 22 additions & 0 deletions rust-bindings/src/expr/format_string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ impl PyFormatString {
self.inner.is_literal()
}

/// Number of parsed segments: literal runs and ``{{...}}`` expressions.
///
/// A format string with more than one segment always concatenates to
/// a single string on resolution; only a whole-field single-expression
/// format string can resolve to ``None`` or to a list. ``is_literal``
/// distinguishes the two single-segment cases.
fn segment_count(&self) -> usize {
self.inner.segment_count()
}

/// The literal (non-expression) text runs, in order.
///
/// Literal runs appear verbatim in every possible resolution, so a
/// property that holds for one holds for every string this format
/// string can resolve to. Expression source text is not included.
fn literal_segments(&self) -> Vec<String> {
self.inner
.literal_segments()
.map(|s| s.to_string())
.collect()
}

/// Copy symbol table entries referenced by this format string's expressions
/// from `source` into `dest`. Only copies the actual values referenced,
/// stopping at property/method access (e.g. for `Param.Name.upper()`,
Expand Down
7 changes: 7 additions & 0 deletions specs/python-expr-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,13 @@ fs.is_literal() # False
fs.expression_names() # ["Param.Frame"]
fs.has_complex_expressions() # False (simple name reference)

# Static shape: literal runs appear verbatim in every resolution, and a
# format string with more than one segment always concatenates to a
# single string (only a whole-field single expression can resolve to
# None or to a list).
fs.segment_count() # 2
fs.literal_segments() # ["render --frame "]

# Resolve
st = SymbolTable({"Param.Frame": 42})
fs.resolve_string(st) # "render --frame 42"
Expand Down
19 changes: 19 additions & 0 deletions src/openjd/_openjd_rs.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,25 @@ class FormatString:
def has_complex_expressions(self) -> builtins.bool: ...
def expression_names(self) -> builtins.list[builtins.str]: ...
def is_literal(self) -> builtins.bool: ...
def segment_count(self) -> builtins.int:
r"""
Number of parsed segments: literal runs and ``{{...}}`` expressions.

A format string with more than one segment always concatenates to
a single string on resolution; only a whole-field single-expression
format string can resolve to ``None`` or to a list. ``is_literal``
distinguishes the two single-segment cases.
"""

def literal_segments(self) -> builtins.list[builtins.str]:
r"""
The literal (non-expression) text runs, in order.

Literal runs appear verbatim in every possible resolution, so a
property that holds for one holds for every string this format
string can resolve to. Expression source text is not included.
"""

def copy_used_symtab_values(self, source: SymbolTable, dest: typing.Any) -> None:
r"""
Copy symbol table entries referenced by this format string's expressions
Expand Down
93 changes: 93 additions & 0 deletions test/openjd/expr/test_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,99 @@ def test_path_not_in_list(self) -> None:
)


class TestListMembershipElementTypeCheck:
"""``in`` / ``not in`` type-check the item against the list's element type
(Expression Language §2.1.3: ``__contains__(list: list[T], item: T)``).

openjd-expr 0.8.0 (openjd-rs#396). Before it, ``'a' in [1, 2]`` evaluated to
``False`` where the spec gives it no signature at all. Cases mirror the
upstream ``test_comparison.rs``; the int/float and path/string coercions the
equality rule already allowed are kept.
"""

@pytest.mark.parametrize(
"expr, detail",
[
(
"'1' in [1, 2, 3]",
"item of type string is not compatible with the element type int of list[int]",
),
(
"1 in ['a', 'b']",
"item of type int is not compatible with the element type string of list[string]",
),
(
"true in [1, 2]",
"item of type bool is not compatible with the element type int of list[int]",
),
(
"null in [1, 2]",
"item of type nulltype is not compatible with the element type int of list[int]",
),
(
"['a'] in [[1], [2]]",
"item of type list[string] is not compatible with the element type list[int] of list[list[int]]",
),
(
"'a' in [x for x in [1, 2]]",
"item of type string is not compatible with the element type int of list[int]",
),
(
"[1, 2] in [1, 2]",
"item of type list[int] is not compatible with the element type int of list[int]",
),
],
)
def test_incompatible_item_type_is_refused(self, expr: str, detail: str) -> None:
with pytest.raises(ExpressionError) as excinfo:
evaluate_expression(expr)
assert f"Cannot use 'in' operator: {detail}" in str(excinfo.value)

def test_not_in_is_refused_the_same_way(self) -> None:
with pytest.raises(ExpressionError) as excinfo:
evaluate_expression("'a' not in [1, 2]")
assert (
"Cannot use 'not in' operator: item of type string is not compatible "
"with the element type int of list[int]"
) in str(excinfo.value)

@pytest.mark.parametrize(
"expr, expected",
[
("1 in [1.0, 2.0]", True),
("3 in [1.0, 2.0]", False),
("1.0 in [1, 2]", True),
("1.5 in [1, 2]", False),
("[1] in [[1.0], [2.0]]", True),
("[1.5] in [[1], [2]]", False),
],
)
def test_int_float_coercion_is_kept(self, expr: str, expected: bool) -> None:
assert evaluate_expression(expr).item() is expected

@pytest.mark.parametrize(
"expr, expected",
[
("path(['/a']) in ['/a', '/b']", True),
("'/a' in [path(['/a']), path(['/b'])]", True),
("'/c' in [path(['/a']), path(['/b'])]", False),
],
)
def test_path_string_coercion_is_kept(self, expr: str, expected: bool) -> None:
assert evaluate_expression(expr, path_format=PathFormat.POSIX).item() is expected

@pytest.mark.parametrize(
"expr",
["1 in []", "'a' in []", "path(['/a']) in []", "[1] in []", "[] in []"],
)
def test_empty_list_accepts_any_item_type(self, expr: str) -> None:
assert evaluate_expression(expr, path_format=PathFormat.POSIX).item() is False

def test_empty_list_item_against_nested_lists(self) -> None:
assert evaluate_expression("[] in [[1]]").item() is False
assert evaluate_expression("[] in [[]]").item() is True


class TestSortedReversed:
"""Tests for sorted() and reversed() functions."""

Expand Down
33 changes: 33 additions & 0 deletions test/openjd/expr/test_parse_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,39 @@ def test_has_complex_expressions(self):
assert not FormatString("{{Param.Name}}").has_complex_expressions()
assert FormatString("{{Param.A + Param.B}}").has_complex_expressions()

@pytest.mark.parametrize(
"raw, count",
[
("hello", 1),
("{{Param.X}}", 1),
("v{{Param.X}}", 2),
("{{Param.X}}-{{Param.Y}}", 3),
("", 0),
],
)
def test_segment_count(self, raw: str, count: int):
# Cases mirror openjd-expr's segment_count_by_shape. A single literal
# run and a whole-field expression are both one segment.
assert FormatString(raw).segment_count() == count

def test_segment_count_single_segment_disambiguated_by_is_literal(self):
assert FormatString("hello").is_literal()
assert not FormatString("{{Param.X}}").is_literal()
assert FormatString("").is_literal()

@pytest.mark.parametrize(
"raw, literals",
[
("a-{{ Param.X }}-b{{ 'y' }}", ["a-", "-b"]),
("{{ Param.X }}", []),
("plain", ["plain"]),
("", []),
],
)
def test_literal_segments(self, raw: str, literals: list[str]):
# Expression source text is excluded, so "'y'" never appears.
assert FormatString(raw).literal_segments() == literals

def test_literal_resolve(self):
fs = FormatString("no interpolation")
st = SymbolTable()
Expand Down
Loading
Loading