valid8r 0.5.7__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of valid8r might be problematic. Click here for more details.

valid8r/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- __version__ = '0.1.0'
6
+ __version__ = '0.5.8'
7
7
 
8
8
  # Public API re-exports for concise imports
9
9
  # Modules
valid8r/core/parsers.py CHANGED
@@ -45,6 +45,9 @@ except ImportError:
45
45
  EmailNotValidError = None # type: ignore[assignment,misc]
46
46
  validate_email = None # type: ignore[assignment]
47
47
 
48
+ import base64
49
+ import binascii
50
+ import json
48
51
  from dataclasses import dataclass
49
52
  from ipaddress import (
50
53
  IPv4Address,
@@ -70,6 +73,11 @@ E = TypeVar('E', bound=Enum)
70
73
 
71
74
  ISO_DATE_LENGTH = 10
72
75
 
76
+ # Compiled regex patterns for phone parsing (cached for performance)
77
+ _PHONE_EXTENSION_PATTERN = re.compile(r'\s*[,;]\s*(\d+)$|\s+(?:x|ext\.?|extension)\s*(\d+)$', re.IGNORECASE)
78
+ _PHONE_VALID_CHARS_PATTERN = re.compile(r'^[\d\s()\-+.]+$', re.MULTILINE)
79
+ _PHONE_DIGIT_EXTRACTION_PATTERN = re.compile(r'\D')
80
+
73
81
 
74
82
  def parse_int(input_value: str, error_message: str | None = None) -> Maybe[int]:
75
83
  """Parse a string to an integer."""
@@ -1329,8 +1337,7 @@ def parse_phone(text: str | None, *, region: str = 'US', strict: bool = False) -
1329
1337
 
1330
1338
  # Extract extension if present
1331
1339
  extension = None
1332
- extension_pattern = r'\s*[,;]\s*(\d+)$|\s+(?:x|ext\.?|extension)\s*(\d+)$'
1333
- extension_match = re.search(extension_pattern, s, re.IGNORECASE)
1340
+ extension_match = _PHONE_EXTENSION_PATTERN.search(s)
1334
1341
  if extension_match:
1335
1342
  # Get the captured group (either group 1 or 2)
1336
1343
  extension = extension_match.group(1) or extension_match.group(2)
@@ -1342,11 +1349,11 @@ def parse_phone(text: str | None, *, region: str = 'US', strict: bool = False) -
1342
1349
 
1343
1350
  # Check for invalid characters before extracting digits
1344
1351
  # Allow only: digits, whitespace (including tabs/newlines), ()-.+ and common separators
1345
- if not re.match(r'^[\d\s()\-+.]+$', s, re.MULTILINE):
1352
+ if not _PHONE_VALID_CHARS_PATTERN.match(s):
1346
1353
  return Maybe.failure('Invalid format: phone number contains invalid characters')
1347
1354
 
1348
1355
  # Extract only digits
1349
- digits = re.sub(r'\D', '', s)
1356
+ digits = _PHONE_DIGIT_EXTRACTION_PATTERN.sub('', s)
1350
1357
 
1351
1358
  # Check for strict mode - original must have formatting
1352
1359
  if strict and text.strip() == digits:
@@ -1404,3 +1411,341 @@ def parse_phone(text: str | None, *, region: str = 'US', strict: bool = False) -
1404
1411
  extension=extension,
1405
1412
  )
1406
1413
  )
1414
+
1415
+
1416
+ def parse_slug(
1417
+ text: str,
1418
+ *,
1419
+ min_length: int | None = None,
1420
+ max_length: int | None = None,
1421
+ ) -> Maybe[str]:
1422
+ """Parse a URL-safe slug (lowercase letters, numbers, hyphens only).
1423
+
1424
+ A valid slug contains only lowercase letters, numbers, and hyphens.
1425
+ Cannot start/end with hyphen or have consecutive hyphens.
1426
+
1427
+ Args:
1428
+ text: String to validate as slug
1429
+ min_length: Minimum length (optional)
1430
+ max_length: Maximum length (optional)
1431
+
1432
+ Returns:
1433
+ Maybe[str]: Success with slug or Failure with error
1434
+
1435
+ Examples:
1436
+ >>> from valid8r.core.parsers import parse_slug
1437
+ >>>
1438
+ >>> # Valid slugs
1439
+ >>> parse_slug('hello-world').value_or(None)
1440
+ 'hello-world'
1441
+ >>> parse_slug('blog-post-123').value_or(None)
1442
+ 'blog-post-123'
1443
+ >>> parse_slug('a').value_or(None)
1444
+ 'a'
1445
+ >>>
1446
+ >>> # With length constraints
1447
+ >>> parse_slug('hello', min_length=5).value_or(None)
1448
+ 'hello'
1449
+ >>> parse_slug('hello', max_length=10).value_or(None)
1450
+ 'hello'
1451
+ >>>
1452
+ >>> # Invalid slugs
1453
+ >>> parse_slug('').is_failure()
1454
+ True
1455
+ >>> parse_slug('Hello-World').is_failure()
1456
+ True
1457
+ >>> parse_slug('hello_world').is_failure()
1458
+ True
1459
+ >>> parse_slug('-hello').is_failure()
1460
+ True
1461
+ >>> parse_slug('hello-').is_failure()
1462
+ True
1463
+ >>> parse_slug('hello--world').is_failure()
1464
+ True
1465
+ >>>
1466
+ >>> # Length constraint failures
1467
+ >>> parse_slug('hi', min_length=5).is_failure()
1468
+ True
1469
+ >>> parse_slug('very-long-slug', max_length=5).is_failure()
1470
+ True
1471
+ """
1472
+ if not text:
1473
+ return Maybe.failure('Slug cannot be empty')
1474
+
1475
+ # Check length constraints
1476
+ if min_length is not None and len(text) < min_length:
1477
+ return Maybe.failure(f'Slug is too short (minimum {min_length} characters)')
1478
+
1479
+ if max_length is not None and len(text) > max_length:
1480
+ return Maybe.failure(f'Slug is too long (maximum {max_length} characters)')
1481
+
1482
+ # Check for leading hyphen
1483
+ if text.startswith('-'):
1484
+ return Maybe.failure('Slug cannot start with a hyphen')
1485
+
1486
+ # Check for trailing hyphen
1487
+ if text.endswith('-'):
1488
+ return Maybe.failure('Slug cannot end with a hyphen')
1489
+
1490
+ # Check for consecutive hyphens
1491
+ if '--' in text:
1492
+ return Maybe.failure('Slug cannot contain consecutive hyphens')
1493
+
1494
+ # Check for invalid characters (not lowercase, digit, or hyphen)
1495
+ if not re.match(r'^[a-z0-9-]+$', text):
1496
+ # Check specifically for uppercase
1497
+ if any(c.isupper() for c in text):
1498
+ return Maybe.failure('Slug must contain only lowercase letters, numbers, and hyphens')
1499
+ return Maybe.failure('Slug contains invalid characters')
1500
+
1501
+ return Maybe.success(text)
1502
+
1503
+
1504
+ def parse_json(text: str) -> Maybe[object]:
1505
+ """Parse a JSON string into a Python object.
1506
+
1507
+ Supports all JSON types: objects, arrays, strings, numbers, booleans, null.
1508
+
1509
+ Args:
1510
+ text: JSON-formatted string
1511
+
1512
+ Returns:
1513
+ Maybe[object]: Success with parsed object or Failure with error
1514
+
1515
+ Examples:
1516
+ >>> from valid8r.core.parsers import parse_json
1517
+ >>>
1518
+ >>> # JSON objects
1519
+ >>> parse_json('{"name": "Alice", "age": 30}').value_or(None)
1520
+ {'name': 'Alice', 'age': 30}
1521
+ >>>
1522
+ >>> # JSON arrays
1523
+ >>> parse_json('[1, 2, 3, 4, 5]').value_or(None)
1524
+ [1, 2, 3, 4, 5]
1525
+ >>>
1526
+ >>> # JSON primitives
1527
+ >>> parse_json('"hello world"').value_or(None)
1528
+ 'hello world'
1529
+ >>> parse_json('42').value_or(None)
1530
+ 42
1531
+ >>> parse_json('true').value_or(None)
1532
+ True
1533
+ >>> parse_json('false').value_or(None)
1534
+ False
1535
+ >>> parse_json('null').value_or(None)
1536
+ >>>
1537
+ >>> # Invalid JSON
1538
+ >>> parse_json('').is_failure()
1539
+ True
1540
+ >>> parse_json('{invalid}').is_failure()
1541
+ True
1542
+ >>> parse_json('{"name": "Alice"').is_failure()
1543
+ True
1544
+ """
1545
+ if not text:
1546
+ return Maybe.failure('JSON input cannot be empty')
1547
+
1548
+ try:
1549
+ result = json.loads(text)
1550
+ return Maybe.success(result)
1551
+ except json.JSONDecodeError as e:
1552
+ return Maybe.failure(f'Invalid JSON: {e.msg}')
1553
+
1554
+
1555
+ def parse_base64(text: str) -> Maybe[bytes]:
1556
+ r"""Parse and decode a base64-encoded string.
1557
+
1558
+ Accepts both standard and URL-safe base64, with or without padding.
1559
+ Handles whitespace and newlines within the base64 string.
1560
+
1561
+ Args:
1562
+ text: Base64-encoded string
1563
+
1564
+ Returns:
1565
+ Maybe[bytes]: Success with decoded bytes or Failure with error
1566
+
1567
+ Examples:
1568
+ >>> from valid8r.core.parsers import parse_base64
1569
+ >>>
1570
+ >>> # Standard base64 with padding
1571
+ >>> parse_base64('SGVsbG8gV29ybGQ=').value_or(None)
1572
+ b'Hello World'
1573
+ >>>
1574
+ >>> # Standard base64 without padding
1575
+ >>> parse_base64('SGVsbG8gV29ybGQ').value_or(None)
1576
+ b'Hello World'
1577
+ >>>
1578
+ >>> # URL-safe base64 (hyphens and underscores)
1579
+ >>> parse_base64('A-A=').is_success()
1580
+ True
1581
+ >>> parse_base64('Pz8_').is_success()
1582
+ True
1583
+ >>>
1584
+ >>> # Base64 with whitespace (automatically stripped)
1585
+ >>> parse_base64(' SGVsbG8gV29ybGQ= ').value_or(None)
1586
+ b'Hello World'
1587
+ >>>
1588
+ >>> # Invalid base64
1589
+ >>> parse_base64('').is_failure()
1590
+ True
1591
+ >>> parse_base64('Not@Valid!').is_failure()
1592
+ True
1593
+ >>> parse_base64('====').is_failure()
1594
+ True
1595
+ """
1596
+ # Strip all whitespace (including internal newlines)
1597
+ text = ''.join(text.split())
1598
+
1599
+ if not text:
1600
+ return Maybe.failure('Base64 input cannot be empty')
1601
+
1602
+ try:
1603
+ # Replace URL-safe characters with standard base64
1604
+ text = text.replace('-', '+').replace('_', '/')
1605
+
1606
+ # Add padding if missing
1607
+ missing_padding = len(text) % 4
1608
+ if missing_padding:
1609
+ text += '=' * (4 - missing_padding)
1610
+
1611
+ decoded = base64.b64decode(text, validate=True)
1612
+ return Maybe.success(decoded)
1613
+ except (ValueError, binascii.Error):
1614
+ return Maybe.failure('Base64 contains invalid characters')
1615
+
1616
+
1617
+ def parse_jwt(text: str) -> Maybe[str]:
1618
+ """Parse and validate a JWT (JSON Web Token) structure.
1619
+
1620
+ Validates that the JWT has exactly three parts (header.payload.signature)
1621
+ separated by dots, and that each part is valid base64url.
1622
+ Also validates that header and payload are valid JSON.
1623
+
1624
+ Note: This function validates JWT structure only. It does NOT verify
1625
+ the cryptographic signature. Use a dedicated JWT library (e.g., PyJWT)
1626
+ for signature verification and claims validation.
1627
+
1628
+ Args:
1629
+ text: JWT string to validate
1630
+
1631
+ Returns:
1632
+ Maybe[str]: Success with original JWT or Failure with error
1633
+
1634
+ Examples:
1635
+ >>> from valid8r.core.parsers import parse_jwt
1636
+ >>>
1637
+ >>> # Valid JWT (structure only - signature not verified)
1638
+ >>> jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig'
1639
+ >>> parse_jwt(jwt).is_success()
1640
+ True
1641
+ >>>
1642
+ >>> # JWT with whitespace (automatically stripped)
1643
+ >>> parse_jwt(' ' + jwt + ' ').is_success()
1644
+ True
1645
+ >>>
1646
+ >>> # Invalid: empty string
1647
+ >>> parse_jwt('').is_failure()
1648
+ True
1649
+ >>>
1650
+ >>> # Invalid: wrong number of parts
1651
+ >>> parse_jwt('header.payload').is_failure()
1652
+ True
1653
+ >>> parse_jwt('a.b.c.d').is_failure()
1654
+ True
1655
+ >>>
1656
+ >>> # Invalid: non-base64url encoding
1657
+ >>> parse_jwt('not-base64!.eyJzdWIiOiIxMjM0In0.sig').is_failure()
1658
+ True
1659
+ >>>
1660
+ >>> # Invalid: non-JSON header/payload
1661
+ >>> parse_jwt('bm90anNvbg==.eyJzdWIiOiIxMjM0In0.sig').is_failure()
1662
+ True
1663
+ """
1664
+ # Strip whitespace
1665
+ text = text.strip()
1666
+
1667
+ if not text:
1668
+ return Maybe.failure('JWT cannot be empty')
1669
+
1670
+ parts = text.split('.')
1671
+ if len(parts) != 3:
1672
+ return Maybe.failure('JWT must have exactly three parts separated by dots')
1673
+
1674
+ # Helper to convert base64url to base64 with padding
1675
+ def decode_base64url(part: str) -> bytes:
1676
+ base64_part = part.replace('-', '+').replace('_', '/')
1677
+ missing_padding = len(base64_part) % 4
1678
+ if missing_padding:
1679
+ base64_part += '=' * (4 - missing_padding)
1680
+ return base64.b64decode(base64_part, validate=True)
1681
+
1682
+ # Validate header (part 0)
1683
+ if not parts[0]:
1684
+ return Maybe.failure('JWT header cannot be empty')
1685
+
1686
+ try:
1687
+ header_bytes = decode_base64url(parts[0])
1688
+ json.loads(header_bytes)
1689
+ except (ValueError, binascii.Error):
1690
+ return Maybe.failure('JWT header is not valid base64')
1691
+ except json.JSONDecodeError:
1692
+ return Maybe.failure('JWT header is not valid JSON')
1693
+
1694
+ # Validate payload (part 1)
1695
+ if not parts[1]:
1696
+ return Maybe.failure('JWT payload cannot be empty')
1697
+
1698
+ try:
1699
+ payload_bytes = decode_base64url(parts[1])
1700
+ json.loads(payload_bytes)
1701
+ except (ValueError, binascii.Error):
1702
+ return Maybe.failure('JWT payload is not valid base64')
1703
+ except json.JSONDecodeError:
1704
+ return Maybe.failure('JWT payload is not valid JSON')
1705
+
1706
+ # Validate signature (part 2)
1707
+ if not parts[2]:
1708
+ return Maybe.failure('JWT signature cannot be empty')
1709
+
1710
+ try:
1711
+ decode_base64url(parts[2])
1712
+ except (ValueError, binascii.Error):
1713
+ return Maybe.failure('JWT signature is not valid base64')
1714
+
1715
+ return Maybe.success(text)
1716
+
1717
+
1718
+ # Public API exports
1719
+ __all__ = [
1720
+ 'EmailAddress',
1721
+ 'PhoneNumber',
1722
+ 'UrlParts',
1723
+ 'create_parser',
1724
+ 'make_parser',
1725
+ 'parse_base64',
1726
+ 'parse_bool',
1727
+ 'parse_cidr',
1728
+ 'parse_complex',
1729
+ 'parse_date',
1730
+ 'parse_decimal',
1731
+ 'parse_dict',
1732
+ 'parse_dict_with_validation',
1733
+ 'parse_email',
1734
+ 'parse_enum',
1735
+ 'parse_float',
1736
+ 'parse_int',
1737
+ 'parse_int_with_validation',
1738
+ 'parse_ip',
1739
+ 'parse_ipv4',
1740
+ 'parse_ipv6',
1741
+ 'parse_json',
1742
+ 'parse_jwt',
1743
+ 'parse_list',
1744
+ 'parse_list_with_validation',
1745
+ 'parse_phone',
1746
+ 'parse_set',
1747
+ 'parse_slug',
1748
+ 'parse_url',
1749
+ 'parse_uuid',
1750
+ 'validated_parser',
1751
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: valid8r
3
- Version: 0.5.7
3
+ Version: 0.6.0
4
4
  Summary: Clean, flexible input validation for Python applications
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,8 +1,8 @@
1
- valid8r/__init__.py,sha256=Yg46vPVbLZvty-FSXkiJ3IkB6hg20pYcp6nWFidIfkk,447
1
+ valid8r/__init__.py,sha256=2fzSl6XtKX44sdqzf0GBTn4oEaCvhmyGkdsPDMJjZz8,447
2
2
  valid8r/core/__init__.py,sha256=ASOdzqCtpZHbHjjYMZkb78Z-nKxtD26ruTY0bd43ImA,520
3
3
  valid8r/core/combinators.py,sha256=KvRiDEqoZgH58cBYPO6SW9pdtkyijk0lS8aGSB5DbO4,2349
4
4
  valid8r/core/maybe.py,sha256=xT1xbiLVKohZ2aeaDZoKjT0W6Vk_PPwKbZXpIfsP7hc,4359
5
- valid8r/core/parsers.py,sha256=ur2wS5_-YHAYaJVwi1SJv6P0TRHp6W799VVqrwV45rg,45319
5
+ valid8r/core/parsers.py,sha256=U-fszQfRmUzT_PMhmVVlUnTpY7CooeG9rr3Lxv5fIfs,55866
6
6
  valid8r/core/validators.py,sha256=oCrRQ2wIPNkqQXy-hJ7sQ9mJAvxtEtGhoy7WvehWqTc,5756
7
7
  valid8r/prompt/__init__.py,sha256=XYB3NEp-tmqT6fGmETVEeXd7Urj0M4ijlwdRAjj-rG8,175
8
8
  valid8r/prompt/basic.py,sha256=fLWuN-oiVZyaLdbcW5GHWpoGQ82RG0j-1n7uMYDfOb8,6008
@@ -11,8 +11,8 @@ valid8r/testing/__init__.py,sha256=8mk54zt0Ai2dK0a3GMOTfDPsVQWXaS6uvQJDrkRV9hs,7
11
11
  valid8r/testing/assertions.py,sha256=9KGz1JooCoyikyxMX7VuXB9VYAtj-4H_LPYFGdvS-ps,1820
12
12
  valid8r/testing/generators.py,sha256=kAV6NRO9x1gPy0BfGs07ETVxjpTIxOZyV9wH2BA1nHA,8791
13
13
  valid8r/testing/mock_input.py,sha256=9GRT7h0PCh9Dea-OcQ5Uls7YqhsTdqMWuX6I6ZlW1aw,2334
14
- valid8r-0.5.7.dist-info/METADATA,sha256=U6MKsIBAkAYQ_jjlQ9sBm6ceTgJm2gOsOCYG4VBz44U,9096
15
- valid8r-0.5.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
16
- valid8r-0.5.7.dist-info/entry_points.txt,sha256=H_24A4zUgnKIXAIRosJliIcntyqMfmcgKh5_Prl7W18,79
17
- valid8r-0.5.7.dist-info/licenses/LICENSE,sha256=JpEmJvRYOTIUt0UjgvpDrd3U94Wnbt_Grr5z-xU2jtk,1066
18
- valid8r-0.5.7.dist-info/RECORD,,
14
+ valid8r-0.6.0.dist-info/METADATA,sha256=e_-g2J7KFPipwQ2LJX-EnZHY4ihqicxGlfw2mIJ0DWA,9096
15
+ valid8r-0.6.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
16
+ valid8r-0.6.0.dist-info/entry_points.txt,sha256=H_24A4zUgnKIXAIRosJliIcntyqMfmcgKh5_Prl7W18,79
17
+ valid8r-0.6.0.dist-info/licenses/LICENSE,sha256=JpEmJvRYOTIUt0UjgvpDrd3U94Wnbt_Grr5z-xU2jtk,1066
18
+ valid8r-0.6.0.dist-info/RECORD,,