diff options
| -rw-r--r-- | edify/library/contact/phone.py | 90 | ||||
| -rw-r--r-- | tests/library/algebra.test.py | 77 | ||||
| -rw-r--r-- | tests/library/corpora/hostname.toml | 15 | ||||
| -rw-r--r-- | tests/library/corpora/isbn.toml | 13 | ||||
| -rw-r--r-- | tests/library/corpora/phone.toml | 25 | ||||
| -rw-r--r-- | tests/library/corpora/port.toml | 17 | ||||
| -rw-r--r-- | tests/library/corpora/postal.toml | 35 | ||||
| -rw-r--r-- | tests/library/corpora/semver.toml | 18 | ||||
| -rw-r--r-- | tests/library/corpora/slug.toml | 17 | ||||
| -rw-r--r-- | tests/library/corpora/vin.toml | 11 | ||||
| -rw-r--r-- | tests/library/invariants.test.py | 23 | ||||
| -rw-r--r-- | tests/snapshots/library/phone.regex | 2 |
12 files changed, 285 insertions, 58 deletions
diff --git a/edify/library/contact/phone.py b/edify/library/contact/phone.py index ebfc5a9..678dc8b 100644 --- a/edify/library/contact/phone.py +++ b/edify/library/contact/phone.py @@ -1,69 +1,61 @@ -"""``phone`` — permissive international or short-code phone-number shape.""" +"""``phone`` — multi-locale phone shapes: international/national display and service codes.""" from __future__ import annotations from edify import Pattern, any_of -_international = ( +_separator = Pattern().optional().any_of_chars(" .-") + +_plain_group = Pattern().between(1, 4).digit() +_parenthesised_group = Pattern().char("(").between(1, 4).digit().char(")") +_group = any_of(_plain_group, _parenthesised_group) + +_international_prefix = ( Pattern() .optional() - .char("+") - .between_lazy(1, 4) - .digit() - .optional() - .any_of() - .char("-") - .char(".") - .whitespace_char() - .end() - .optional() - .char("(") - .between_lazy(1, 3) - .digit() - .optional() - .char(")") - .optional() + .group() .any_of() - .char("-") - .char(".") - .whitespace_char() + .char("+") + .string("00") .end() - .between(1, 4) - .digit() .optional() - .any_of() - .char("-") - .char(".") - .whitespace_char() + .any_of_chars(" .-") .end() - .between(1, 4) - .digit() - .optional() - .any_of() - .char("-") - .char(".") - .whitespace_char() +) + +_number = ( + Pattern() + .subexpression(_international_prefix) + .subexpression(_group) + .between(1, 7) + .group() + .subexpression(_separator) + .subexpression(_group) .end() - .between(1, 9) - .digit() ) -_short = Pattern().between(2, 4).digit() -phone = Pattern().start_of_input().subexpression(any_of(_international, _short)).end_of_input() -"""Callable :class:`Pattern` for permissive international or 2-4 digit short-code phone shapes. +_service = Pattern().between(2, 6).digit() + +phone = Pattern().start_of_input().subexpression(any_of(_number, _service)).end_of_input() +"""Callable :class:`Pattern` for multi-locale phone-number display shapes. Guarantees: - * International form: optional ``+``, 1-4 country-code digits, optional - parenthesised area code, and dash/dot/space separators. - * Short-code fallback for 2-4 digit service numbers. + * International form: an optional ``+`` or ``00`` prefix, then two to eight + digit groups of one to four digits each, separated by an optional single + space, dot, or dash. + * Any single group may be parenthesised, covering leading and inline area + codes such as ``(555) 123-4567`` and ``+44 (0)20 7946 0958``. + * Variable national grouping — three-group North American, five-group French, + and every shape in between are accepted. + * Service and short codes of two to six digits, covering emergency and + abbreviated-dialling numbers. * Anchored at both ends. Does not guarantee: - * ITU E.164 canonical form — the pattern is deliberately permissive to - accept the display forms real inputs use. - * Per-country structural validity — full locale coverage lands with the - dedicated locale-expansion work. - * Missed shapes (parenthesised area codes with mixed separators, 4-digit-only - service numbers outside the short-code range) — expect false rejects for - unusual inputs. + * ITU E.164 canonical form or per-country structural validity — the pattern + accepts the permissive display forms real inputs use, not a single locale's + exact digit count. + * Separator discipline beyond a single character between groups — doubled + separators such as ``555--123--4567`` are rejected. + * Letters or vanity spellings — only digits, separators, and parentheses match. """ diff --git a/tests/library/algebra.test.py b/tests/library/algebra.test.py new file mode 100644 index 0000000..ee4005d --- /dev/null +++ b/tests/library/algebra.test.py @@ -0,0 +1,77 @@ +"""Chain <-> operator-algebra equivalence: ``+``, ``|`` and ``.use()`` emit the fluent-chain regex. + +Every character-class module constant is driven through both surfaces and the two +emitted regex strings are asserted identical, so the operator algebra can never +drift from the fluent builder it stands in for. +""" + +import pytest + +from edify import ( + ALPHANUMERIC, + ANY_CHAR, + CARRIAGE_RETURN, + DIGIT, + LETTER, + LOWERCASE, + NEW_LINE, + NON_DIGIT, + NON_WHITESPACE, + NON_WORD, + NULL_BYTE, + TAB, + UPPERCASE, + WHITESPACE, + WORD, + Pattern, +) + +_CONSTANTS: dict[str, Pattern] = { + "ANY_CHAR": ANY_CHAR, + "WHITESPACE": WHITESPACE, + "NON_WHITESPACE": NON_WHITESPACE, + "DIGIT": DIGIT, + "NON_DIGIT": NON_DIGIT, + "WORD": WORD, + "NON_WORD": NON_WORD, + "NEW_LINE": NEW_LINE, + "CARRIAGE_RETURN": CARRIAGE_RETURN, + "TAB": TAB, + "NULL_BYTE": NULL_BYTE, + "LETTER": LETTER, + "UPPERCASE": UPPERCASE, + "LOWERCASE": LOWERCASE, + "ALPHANUMERIC": ALPHANUMERIC, +} +_IDS = list(_CONSTANTS) +_VALUES = list(_CONSTANTS.values()) + + [email protected]("constant", _VALUES, ids=_IDS) +def test_plus_matches_chain_subexpression(constant: Pattern): + operator_form = (constant + DIGIT).to_regex_string() + chain_form = Pattern().subexpression(constant).subexpression(DIGIT).to_regex_string() + assert operator_form == chain_form + + [email protected]("constant", _VALUES, ids=_IDS) +def test_or_matches_chain_any_of(constant: Pattern): + operator_form = (constant | DIGIT).to_regex_string() + chain_form = ( + Pattern().any_of().subexpression(constant).subexpression(DIGIT).end().to_regex_string() + ) + assert operator_form == chain_form + + [email protected]("constant", _VALUES, ids=_IDS) +def test_use_matches_chain_subexpression(constant: Pattern): + use_form = Pattern().use(constant).to_regex_string() + chain_form = Pattern().subexpression(constant).to_regex_string() + assert use_form == chain_form + + [email protected]("constant", _VALUES, ids=_IDS) +def test_operator_and_chain_compile_to_the_same_matcher(constant: Pattern): + operator_compiled = (constant + DIGIT).to_regex().source + chain_compiled = Pattern().subexpression(constant).subexpression(DIGIT).to_regex().source + assert operator_compiled == chain_compiled diff --git a/tests/library/corpora/hostname.toml b/tests/library/corpora/hostname.toml new file mode 100644 index 0000000..23da560 --- /dev/null +++ b/tests/library/corpora/hostname.toml @@ -0,0 +1,15 @@ +accepts = [ + "example.com", + "sub.example.co.uk", + "localhost", + "a.b.c", + "xn--d1acufc.example", +] + +rejects = [ + "", + "-bad.com", + ".com", + "a..b", + "exa mple.com", +] diff --git a/tests/library/corpora/isbn.toml b/tests/library/corpora/isbn.toml new file mode 100644 index 0000000..9028bc5 --- /dev/null +++ b/tests/library/corpora/isbn.toml @@ -0,0 +1,13 @@ +accepts = [ + "978-3-16-148410-0", + "0-306-40615-2", + "9783161484100", + "0306406152", +] + +rejects = [ + "", + "123", + "abc", + "978-3-16-148410-X", +] diff --git a/tests/library/corpora/phone.toml b/tests/library/corpora/phone.toml index 7abe2f7..f7f2b35 100644 --- a/tests/library/corpora/phone.toml +++ b/tests/library/corpora/phone.toml @@ -1,17 +1,36 @@ accepts = [ "+1 555 123 4567", - "+44 20 7946 0958", + "+1-555-123-4567", "555-123-4567", "555.123.4567", - "+1-555-123-4567", + "(555) 123-4567", "5551234567", + "1-800-555-0199", + "+44 20 7946 0958", + "+44 (0)20 7946 0958", "+81 3-1234-5678", + "+91 98765 43210", + "+49 30 12345678", + "+33 1 42 68 53 00", + "+61 2 9374 4000", + "+55 11 91234-5678", + "00 1 555 123 4567", + "112", + "911", + "999", + "311", + "08000", ] rejects = [ "", + " ", "not a phone", "abc-def-ghij", - " ", "555--123--4567", + "++15551234567", + "+", + "1", + "555-", + "(555 123-4567", ] diff --git a/tests/library/corpora/port.toml b/tests/library/corpora/port.toml new file mode 100644 index 0000000..4c715cc --- /dev/null +++ b/tests/library/corpora/port.toml @@ -0,0 +1,17 @@ +accepts = [ + "0", + "1", + "80", + "443", + "8080", + "65535", +] + +rejects = [ + "", + "65536", + "99999", + "-1", + "abc", + "8080a", +] diff --git a/tests/library/corpora/postal.toml b/tests/library/corpora/postal.toml new file mode 100644 index 0000000..5bf05fd --- /dev/null +++ b/tests/library/corpora/postal.toml @@ -0,0 +1,35 @@ +accepts = [ + "90210", + "90210-1234", + "10115", + "75008", + "110001", + "1011 AB", + "NL-1011 AB", + "SW1A 1AA", + "EC1A 1BB", + "GIR 0AA", + "K1A 0B1", + "D02 AF30", + "100-0001", + "01310-100", + "2000", + "00184", + "28001", + "114 55", + "00-001", + "101000", +] + +rejects = [ + "", + " ", + "notazip", + "abcde", + "!!!!!", + "90210-12", + "90210-", + "ZZ99 9ZZ", + "123456789", + "----", +] diff --git a/tests/library/corpora/semver.toml b/tests/library/corpora/semver.toml new file mode 100644 index 0000000..287c6c2 --- /dev/null +++ b/tests/library/corpora/semver.toml @@ -0,0 +1,18 @@ +accepts = [ + "1.0.0", + "2.1.3", + "10.20.30", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-rc.1+build.5", + "1.0.0+build.1", +] + +rejects = [ + "", + "1", + "1.0", + "v1.0.0", + "1.0.0.0", + "01.0.0", +] diff --git a/tests/library/corpora/slug.toml b/tests/library/corpora/slug.toml new file mode 100644 index 0000000..87d5407 --- /dev/null +++ b/tests/library/corpora/slug.toml @@ -0,0 +1,17 @@ +accepts = [ + "hello-world", + "my-post-123", + "a", + "abc", + "x-y-z", +] + +rejects = [ + "", + " ", + "Hello-World", + "hello_world", + "-abc", + "abc-", + "a--b", +] diff --git a/tests/library/corpora/vin.toml b/tests/library/corpora/vin.toml new file mode 100644 index 0000000..bc5909e --- /dev/null +++ b/tests/library/corpora/vin.toml @@ -0,0 +1,11 @@ +accepts = [ + "1HGBH41JXMN109186", + "5YJSA1E26HF000001", +] + +rejects = [ + "", + "1HGBH41JXMN10918", + "ABC", + "1HGBH41JXMN109186Q", +] diff --git a/tests/library/invariants.test.py b/tests/library/invariants.test.py index 662e81c..340f6a4 100644 --- a/tests/library/invariants.test.py +++ b/tests/library/invariants.test.py @@ -18,13 +18,10 @@ def _registered_patterns() -> Iterator[tuple[str, Pattern]]: REGISTERED_PATTERNS: list[tuple[str, Pattern]] = list(_registered_patterns()) +_IDS = [registered_name for registered_name, _ in REGISTERED_PATTERNS] - ("name", "pattern"), - REGISTERED_PATTERNS, - ids=[registered_name for registered_name, _ in REGISTERED_PATTERNS], -) [email protected](("name", "pattern"), REGISTERED_PATTERNS, ids=_IDS) def test_to_regex_string_matches_the_compiled_pattern_source(name: str, pattern: Pattern): emitted_source = pattern.to_regex_string() compiled_source = pattern.to_regex().source @@ -32,3 +29,19 @@ def test_to_regex_string_matches_the_compiled_pattern_source(name: str, pattern: f"library pattern {name!r} emits {emitted_source!r} but its compiled Regex reports " f"{compiled_source!r} — the terminals have drifted apart." ) + + [email protected](("name", "pattern"), REGISTERED_PATTERNS, ids=_IDS) +def test_dict_round_trip_preserves_the_emitted_regex(name: str, pattern: Pattern): + restored = Pattern.from_dict(pattern.to_dict()) + assert restored.to_regex_string() == pattern.to_regex_string(), ( + f"library pattern {name!r} does not survive a to_dict/from_dict round trip." + ) + + [email protected](("name", "pattern"), REGISTERED_PATTERNS, ids=_IDS) +def test_json_round_trip_preserves_the_emitted_regex(name: str, pattern: Pattern): + restored = Pattern.from_json(pattern.to_json()) + assert restored.to_regex_string() == pattern.to_regex_string(), ( + f"library pattern {name!r} does not survive a to_json/from_json round trip." + ) diff --git a/tests/snapshots/library/phone.regex b/tests/snapshots/library/phone.regex index 609f322..49c4e2d 100644 --- a/tests/snapshots/library/phone.regex +++ b/tests/snapshots/library/phone.regex @@ -1 +1 @@ -^(?:\+?\d{1,4}?(?:\s|[\-\.])?\(?\d{1,3}?\)?(?:\s|[\-\.])?\d{1,4}(?:\s|[\-\.])?\d{1,4}(?:\s|[\-\.])?\d{1,9}|\d{2,4})$
\ No newline at end of file +^(?:(?:(?:00|[\+])[ .-]?)?(?:\d{1,4}|\(\d{1,4}\))(?:[ .-]?(?:\d{1,4}|\(\d{1,4}\))){1,7}|\d{2,6})$
\ No newline at end of file |
