aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--edify/library/contact/phone.py90
-rw-r--r--tests/library/algebra.test.py77
-rw-r--r--tests/library/corpora/hostname.toml15
-rw-r--r--tests/library/corpora/isbn.toml13
-rw-r--r--tests/library/corpora/phone.toml25
-rw-r--r--tests/library/corpora/port.toml17
-rw-r--r--tests/library/corpora/postal.toml35
-rw-r--r--tests/library/corpora/semver.toml18
-rw-r--r--tests/library/corpora/slug.toml17
-rw-r--r--tests/library/corpora/vin.toml11
-rw-r--r--tests/library/invariants.test.py23
-rw-r--r--tests/snapshots/library/phone.regex2
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