Skip to content
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
6 changes: 3 additions & 3 deletions src/outlines/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,16 @@ 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
# [-90, 90] and longitude to [-180, 180]; the bounds themselves admit only a
# 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+)?)")

Expand Down
57 changes: 57 additions & 0 deletions src/outlines/types/dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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


Expand Down
98 changes: 98 additions & 0 deletions tests/types/test_dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

from outlines import grammars, types
from outlines.types.dsl import (
BARE_TERMS,
QUOTED_TERMS,
Alternatives,
JsonSchema,
KleenePlus,
Expand Down Expand Up @@ -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"
Expand Down
Loading