valid8r 0.5.9__tar.gz → 0.6.1__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: valid8r
3
- Version: 0.5.9
3
+ Version: 0.6.1
4
4
  Summary: Clean, flexible input validation for Python applications
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "valid8r"
3
- version = "0.5.9"
3
+ version = "0.6.1"
4
4
  description = "Clean, flexible input validation for Python applications"
5
5
  authors = ["Mike Lane <mikelane@gmail.com>"]
6
6
  license = "MIT"
@@ -35,13 +35,9 @@ pydantic-core = "^2.27.0"
35
35
  uuid-utils = "^0.11.0"
36
36
 
37
37
  [tool.poetry.group.dev.dependencies]
38
- fastapi = "^0.119.0"
39
- httpx = "^0.28.1"
40
- livereload = "^2.7.1"
41
38
  mypy = "^1.17.1"
42
39
  pre-commit = "^4.3.0"
43
40
  python-semantic-release = "^9.21.1"
44
- uvicorn = "^0.37.0"
45
41
 
46
42
  [tool.poetry.group.lint.dependencies]
47
43
  isort = "^6.0.1"
@@ -141,9 +137,13 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
141
137
  "D102", # Don't require method docstrings in tests
142
138
  "D103", # Don't require function docstrings in tests
143
139
  "D104", # Don't require init docstrings in tests
140
+ "E501", # Allow long lines in tests
141
+ "ERA001", # Allow commented-out code (test labels)
144
142
  "PGH003", # Allow tests to casually ignore mypy complaints about incorrect types
145
143
  "PLC0415", # Allow imports inside test functions
146
144
  "PLR0913", # Allow tests to have as many arguments as we want
145
+ "PLR2004", # Allow magic values in tests
146
+ "S101", # Allow asserts in tests
147
147
  ]
148
148
  "tests/bdd/steps/*" = [
149
149
  "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