valid8r 0.5.9__tar.gz → 0.6.0__tar.gz
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-0.5.9 → valid8r-0.6.0}/PKG-INFO +1 -1
- {valid8r-0.5.9 → valid8r-0.6.0}/pyproject.toml +5 -1
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/core/parsers.py +309 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/LICENSE +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/README.md +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/__init__.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/core/__init__.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/core/combinators.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/core/maybe.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/core/validators.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/prompt/__init__.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/prompt/basic.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/py.typed +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/testing/__init__.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/testing/assertions.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/testing/generators.py +0 -0
- {valid8r-0.5.9 → valid8r-0.6.0}/valid8r/testing/mock_input.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "valid8r"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Clean, flexible input validation for Python applications"
|
|
5
5
|
authors = ["Mike Lane <mikelane@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -141,9 +141,13 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
|
141
141
|
"D102", # Don't require method docstrings in tests
|
|
142
142
|
"D103", # Don't require function docstrings in tests
|
|
143
143
|
"D104", # Don't require init docstrings in tests
|
|
144
|
+
"E501", # Allow long lines in tests
|
|
145
|
+
"ERA001", # Allow commented-out code (test labels)
|
|
144
146
|
"PGH003", # Allow tests to casually ignore mypy complaints about incorrect types
|
|
145
147
|
"PLC0415", # Allow imports inside test functions
|
|
146
148
|
"PLR0913", # Allow tests to have as many arguments as we want
|
|
149
|
+
"PLR2004", # Allow magic values in tests
|
|
150
|
+
"S101", # Allow asserts in tests
|
|
147
151
|
]
|
|
148
152
|
"tests/bdd/steps/*" = [
|
|
149
153
|
"S101", # Allow asserts in behave tests
|
|
@@ -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,
|
|
@@ -1410,6 +1413,308 @@ def parse_phone(text: str | None, *, region: str = 'US', strict: bool = False) -
|
|
|
1410
1413
|
)
|
|
1411
1414
|
|
|
1412
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
|
+
|
|
1413
1718
|
# Public API exports
|
|
1414
1719
|
__all__ = [
|
|
1415
1720
|
'EmailAddress',
|
|
@@ -1417,6 +1722,7 @@ __all__ = [
|
|
|
1417
1722
|
'UrlParts',
|
|
1418
1723
|
'create_parser',
|
|
1419
1724
|
'make_parser',
|
|
1725
|
+
'parse_base64',
|
|
1420
1726
|
'parse_bool',
|
|
1421
1727
|
'parse_cidr',
|
|
1422
1728
|
'parse_complex',
|
|
@@ -1432,10 +1738,13 @@ __all__ = [
|
|
|
1432
1738
|
'parse_ip',
|
|
1433
1739
|
'parse_ipv4',
|
|
1434
1740
|
'parse_ipv6',
|
|
1741
|
+
'parse_json',
|
|
1742
|
+
'parse_jwt',
|
|
1435
1743
|
'parse_list',
|
|
1436
1744
|
'parse_list_with_validation',
|
|
1437
1745
|
'parse_phone',
|
|
1438
1746
|
'parse_set',
|
|
1747
|
+
'parse_slug',
|
|
1439
1748
|
'parse_url',
|
|
1440
1749
|
'parse_uuid',
|
|
1441
1750
|
'validated_parser',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|