diff --git a/src/outlines/types/__init__.py b/src/outlines/types/__init__.py index bf509c267..5dd822c4f 100644 --- a/src/outlines/types/__init__.py +++ b/src/outlines/types/__init__.py @@ -225,7 +225,7 @@ def __getattr__(self, name): # national dialing prefixes are intentionally excluded, and number assignment # is not validated. For national conventions see the `locale` submodule. # Like the other built-in string types, this is meant for standalone use: inside -# a JSON container it is currently generated unquoted (#1962). +# a JSON container it is JSON-quoted by ``_ensure_json_quoted``. e164 = Regex(r"\+[1-9]\d{1,14}") # Geographic coordinates in signed decimal degrees. Latitude is bounded to @@ -233,8 +233,8 @@ def __getattr__(self, name): # zero fractional part. Leading zeros in the integer part and the # degree-minute-second and cardinal-direction notations are excluded. # These are text forms meant for standalone use: inside a JSON container they -# are currently generated unquoted (#1962), which parses as a number rather -# than the string the schema declares. +# stay bare by classification in ``_ensure_json_quoted``; their patterns are +# number-shaped, so bare is the intended JSON form. latitude = Regex(r"[+-]?(?:90(?:\.0+)?|[1-8]?\d(?:\.\d+)?)") longitude = Regex(r"[+-]?(?:180(?:\.0+)?|(?:1[0-7]\d|[1-9]?\d)(?:\.\d+)?)") diff --git a/src/outlines/types/dsl.py b/src/outlines/types/dsl.py index c69681309..6b95a073a 100644 --- a/src/outlines/types/dsl.py +++ b/src/outlines/types/dsl.py @@ -865,6 +865,53 @@ def _handle_literal(args: tuple) -> Alternatives: return Alternatives([python_types_to_terms(arg) for arg in args]) +def _is_builtin_term(term: Term, names: tuple[str, ...]) -> bool: + """Whether ``term`` is one of the built-in ``outlines.types`` terms by + identity. Built-ins are module-level ``Regex`` singletons; identity (not + equality) keeps a user's own ``Regex`` that happens to share a pattern + from being treated as a built-in.""" + return any(term is getattr(types, name, None) for name in names) + + +# Built-in ``Regex`` singletons that are JSON scalars and must stay bare inside +# containers, and string-shaped singletons that must be JSON-quoted there. +# ``types.string`` is handled separately by the pattern guard in +# ``_ensure_json_quoted``, so it is not listed here. +BARE_TERMS = ( + "integer", + "number", + "boolean", + "digit", + "newline", + "whitespace", + "paragraph", + "sentence", + "date", + "time", + "datetime", + "latitude", + "longitude", +) + +QUOTED_TERMS = ( + "email", + "uuid4", + "ipv4", + "ipv6", + "isbn", + "mac_address", + "semver", + "slug", + "hex_color", + "hex_str", + "credit_card", + "char", + "iban", + "bic", + "e164", +) + + def _ensure_json_quoted(term: Term, quote_regex: bool = False) -> Term: """Wrap ``String`` terms in double quotes for JSON container contexts. @@ -883,6 +930,16 @@ def _ensure_json_quoted(term: Term, quote_regex: bool = False) -> Term: return Alternatives(quoted) if quote_regex and isinstance(term, Regex) and term.pattern != types.string.pattern: return Sequence([String('"'), term, String('"')]) + if isinstance(term, Regex) and term.pattern != types.string.pattern: + # Built-in terms are JSON scalars (bare), JSON strings (quoted), or + # terms that need escaping before they can be quoted. Keep scalars and + # control-character terms bare (escaping them is a follow-up, #1962), + # leave temporals to #1961, and quote the string-shaped built-ins so + # containers like ``list[types.email]`` generate valid JSON. + if _is_builtin_term(term, BARE_TERMS): + return term + if _is_builtin_term(term, QUOTED_TERMS): + return Sequence([String('"'), term, String('"')]) return term diff --git a/tests/types/test_dsl.py b/tests/types/test_dsl.py index fbb6861ac..a08b876a5 100644 --- a/tests/types/test_dsl.py +++ b/tests/types/test_dsl.py @@ -20,6 +20,8 @@ from outlines import grammars, types from outlines.types.dsl import ( + BARE_TERMS, + QUOTED_TERMS, Alternatives, JsonSchema, KleenePlus, @@ -1218,6 +1220,102 @@ def test_e2e_optional_none_not_quoted_in_containers(): assert not _re.fullmatch(literal_pattern, "[None]") +@pytest.mark.parametrize( + "term,value", + [ + (types.uuid4, "123e4567-e89b-42d3-a456-426614174000"), + (types.ipv4, "192.168.1.1"), + (types.ipv6, "2001:db8::1"), + (types.mac_address, "00:1A:2B:3C:4D:5E"), + (types.semver, "1.2.3"), + (types.slug, "hello-world"), + (types.hex_color, "#ff0000"), + (types.hex_str, "0x1f"), + (types.credit_card, "4111111111111111"), + (types.char, "x"), + (types.email, "a@b.com"), + (types.iban, "DE89370400440532013000"), + (types.bic, "DEUTDEFF"), + (types.e164, "+14155552671"), + ], +) +def test_e2e_string_shaped_builtin_terms_quoted_in_containers(term, value): + """String-shaped built-in terms (``list[types.email]`` etc.) must be + JSON-quoted inside containers; the bare spelling is not valid JSON. + ``types.isbn`` is excluded from the parameter set because its pattern + contains ``^``/``$`` anchors that cannot be embedded in a container regex + at all (pre-existing limitation, independent of quoting).""" + list_pattern = to_regex(python_types_to_terms(list[term])) + assert _re.fullmatch(list_pattern, f'["{value}"]') + assert not _re.fullmatch(list_pattern, f"[{value}]") + + dict_pattern = to_regex(python_types_to_terms(dict[str, term])) + assert _re.fullmatch(dict_pattern, f'{{"k":"{value}"}}') + assert not _re.fullmatch(dict_pattern, f'{{"k":{value}}}') + + # Dict keys were already quoted via quote_regex=True; keep that behavior. + key_pattern = to_regex(python_types_to_terms(dict[term, str])) + assert _re.fullmatch(key_pattern, f'{{"{value}":"v"}}') + + +@pytest.mark.parametrize( + "term,value", + [ + (types.integer, "5"), + (types.number, "5.5"), + (types.digit, "7"), + ], +) +def test_e2e_json_scalar_builtin_terms_stay_bare_in_containers(term, value): + """JSON-scalar built-ins keep their bare spelling inside containers.""" + list_pattern = to_regex(python_types_to_terms(list[term])) + assert _re.fullmatch(list_pattern, f"[{value}]") + assert not _re.fullmatch(list_pattern, f'["{value}"]') + + +@pytest.mark.parametrize( + "term,value", + [ + (types.latitude, "45.5"), + (types.longitude, "-73.6"), + ], +) +def test_e2e_number_shaped_builtin_terms_stay_bare_in_containers(term, value): + """Number-shaped built-ins (latitude/longitude) stay bare; they are + classified explicitly rather than falling through the allowlist.""" + list_pattern = to_regex(python_types_to_terms(list[term])) + assert _re.fullmatch(list_pattern, f"[{value}]") + assert not _re.fullmatch(list_pattern, f'["{value}"]') + + +def test_every_builtin_regex_term_is_classified(): + """Every built-in ``Regex`` singleton is classified as bare or quoted, so a + future type PR cannot silently reopen #1962 by falling through.""" + builtins = { + name + for name in dir(types) + if not name.startswith("_") and isinstance(getattr(types, name), Regex) + } + assert not builtins - (set(BARE_TERMS) | set(QUOTED_TERMS) | {"string"}) + + +def test_e2e_control_char_builtin_terms_left_bare_in_containers(): + """Control-character terms are excluded from quoting: wrapping them in + quotes would look fixed while still failing json.loads (see #1962).""" + for term in (types.newline, types.whitespace, types.paragraph, types.sentence): + list_pattern = to_regex(python_types_to_terms(list[term])) + assert list_pattern.startswith("\\[") + assert "\\\"" not in list_pattern + + +def test_e2e_user_regex_term_still_bare_in_containers(): + """A user-supplied Regex that shares a built-in pattern is not treated as a + built-in term and stays bare.""" + user_regex = Regex(r"[a-z]+@[a-z]+\.[a-z]+") + list_pattern = to_regex(python_types_to_terms(list[user_regex])) + assert _re.fullmatch(list_pattern, "[a@b.com]") + + def test_to_regex(): string_term = String("hello") assert to_regex(string_term) == r"hello"