plain 0.63.0__py3-none-any.whl → 0.65.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.
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.65.0](https://github.com/dropseed/plain/releases/plain@0.65.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - Added CIDR notation support to `ALLOWED_HOSTS` for IP address range validation ([c485d21](https://github.com/dropseed/plain/commit/c485d21a8b))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.64.0](https://github.com/dropseed/plain/releases/plain@0.64.0) (2025-09-19)
14
+
15
+ ### What's changed
16
+
17
+ - Added `plain-build` command as a standalone executable ([4b39ca4](https://github.com/dropseed/plain/commit/4b39ca4599))
18
+ - Removed `constant_time_compare` utility function in favor of `hmac.compare_digest` ([55f3f55](https://github.com/dropseed/plain/commit/55f3f5596d))
19
+ - CLI now forces colors in CI environments (GitHub Actions, GitLab CI, etc.) for better output visibility ([56f7d2b](https://github.com/dropseed/plain/commit/56f7d2b312))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - Replace any usage of `plain.utils.crypto.constant_time_compare` with `hmac.compare_digest` or `secrets.compare_digest`
24
+
3
25
  ## [0.63.0](https://github.com/dropseed/plain/releases/plain@0.63.0) (2025-09-12)
4
26
 
5
27
  ### What's changed
plain/cli/formatting.py CHANGED
@@ -1,3 +1,5 @@
1
+ import os
2
+
1
3
  import click
2
4
  from click.formatting import iter_rows, measure_table, term_len, wrap_text
3
5
 
@@ -59,3 +61,13 @@ class PlainHelpFormatter(click.HelpFormatter):
59
61
 
60
62
  class PlainContext(click.Context):
61
63
  formatter_class = PlainHelpFormatter
64
+
65
+ def __init__(self, *args, **kwargs):
66
+ super().__init__(*args, **kwargs)
67
+
68
+ # Force colors in CI environments
69
+ if any(
70
+ os.getenv(var)
71
+ for var in ["CI", "FORCE_COLOR", "GITHUB_ACTIONS", "GITLAB_CI"]
72
+ ) and not any(os.getenv(var) for var in ["NO_COLOR", "PYTEST_CURRENT_TEST"]):
73
+ self.color = True
plain/cli/upgrade.py CHANGED
@@ -153,7 +153,6 @@ def build_prompt(before_after: dict[str, tuple[str | None, str | None]]) -> str:
153
153
  " - Process ALL packages before testing or validation",
154
154
  " - After all packages are updated, run `uv run plain fix --unsafe-fixes` and `uv run plain pre-commit` to check results",
155
155
  " - DO NOT commit any changes",
156
- " - DO NOT run `plain migrate` with the `--no-backup` option",
157
156
  " - Keep code changes minimal and focused - avoid unnecessary comments",
158
157
  "",
159
158
  "3. **Available tools:**",
plain/http/hosts.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ Host validation utilities for ALLOWED_HOSTS functionality.
3
+
4
+ This module provides functions for validating hosts against allowed patterns,
5
+ including domain patterns, wildcards, and CIDR notation for IP ranges.
6
+ """
7
+
8
+ import ipaddress
9
+
10
+ from plain.utils.regex_helper import _lazy_re_compile
11
+
12
+ host_validation_re = _lazy_re_compile(
13
+ r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
14
+ )
15
+
16
+
17
+ def split_domain_port(host: str) -> tuple[str, str]:
18
+ """
19
+ Return a (domain, port) tuple from a given host.
20
+
21
+ Returned domain is lowercased. If the host is invalid, the domain will be
22
+ empty.
23
+ """
24
+ host = host.lower()
25
+
26
+ if not host_validation_re.match(host):
27
+ return "", ""
28
+
29
+ if host[-1] == "]":
30
+ # It's an IPv6 address without a port.
31
+ return host, ""
32
+ bits = host.rsplit(":", 1)
33
+ domain, port = bits if len(bits) == 2 else (bits[0], "")
34
+ # Remove a trailing dot (if present) from the domain.
35
+ domain = domain.removesuffix(".")
36
+ return domain, port
37
+
38
+
39
+ def _is_same_domain(host: str, pattern: str) -> bool:
40
+ """
41
+ Return ``True`` if the host is either an exact match or a match
42
+ to the wildcard pattern.
43
+
44
+ Any pattern beginning with a period matches a domain and all of its
45
+ subdomains. (e.g. ``.example.com`` matches ``example.com`` and
46
+ ``foo.example.com``). Anything else is an exact string match.
47
+ """
48
+ if not pattern:
49
+ return False
50
+
51
+ pattern = pattern.lower()
52
+ return (
53
+ pattern[0] == "."
54
+ and (host.endswith(pattern) or host == pattern[1:])
55
+ or pattern == host
56
+ )
57
+
58
+
59
+ def _parse_ip_address(
60
+ host: str,
61
+ ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
62
+ """
63
+ Parse a host string as an IP address (IPv4 or IPv6).
64
+
65
+ Returns the ipaddress.ip_address object if valid, None otherwise.
66
+ Handles both bracketed and non-bracketed IPv6 addresses.
67
+ """
68
+ # Remove brackets from IPv6 addresses
69
+ if host.startswith("[") and host.endswith("]"):
70
+ host = host[1:-1]
71
+
72
+ try:
73
+ return ipaddress.ip_address(host)
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def _parse_cidr_pattern(
79
+ pattern: str,
80
+ ) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
81
+ """
82
+ Parse a CIDR pattern and return the network object if valid.
83
+
84
+ Returns the ipaddress.ip_network object if valid CIDR notation, None otherwise.
85
+ """
86
+ # Check if it contains a slash (required for CIDR)
87
+ if "/" not in pattern:
88
+ return None
89
+
90
+ # Remove brackets from IPv6 CIDR patterns
91
+ test_pattern = pattern
92
+ if pattern.startswith("[") and "]/" in pattern:
93
+ # Handle format like [2001:db8::]/32
94
+ bracket_end = pattern.find("]/")
95
+ if bracket_end != -1:
96
+ ip_part = pattern[1:bracket_end]
97
+ cidr_part = pattern[bracket_end + 2 :]
98
+ test_pattern = f"{ip_part}/{cidr_part}"
99
+ elif pattern.startswith("[") and pattern.endswith("]") and "/" in pattern:
100
+ # Handle format like [2001:db8::/32] (slash inside brackets)
101
+ test_pattern = pattern[1:-1]
102
+
103
+ try:
104
+ return ipaddress.ip_network(test_pattern, strict=False)
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
110
+ """
111
+ Validate the given host for this site.
112
+
113
+ Check that the host looks valid and matches a host or host pattern in the
114
+ given list of ``allowed_hosts``. Supported patterns:
115
+
116
+ - ``*`` matches anything
117
+ - ``.example.com`` matches a domain and all its subdomains
118
+ (e.g. ``example.com`` and ``sub.example.com``)
119
+ - ``example.com`` matches exactly that domain
120
+ - ``192.168.1.0/24`` matches IP addresses in that CIDR range
121
+ - ``[2001:db8::]/32`` matches IPv6 addresses in that CIDR range
122
+ - ``192.168.1.1`` matches that exact IP address
123
+
124
+ Note: This function assumes that the given host is lowercased and has
125
+ already had the port, if any, stripped off.
126
+
127
+ Return ``True`` for a valid host, ``False`` otherwise.
128
+ """
129
+ # Parse the host as an IP address if possible
130
+ host_ip = _parse_ip_address(host)
131
+
132
+ for pattern in allowed_hosts:
133
+ # Wildcard matches everything
134
+ if pattern == "*":
135
+ return True
136
+
137
+ # Check CIDR notation patterns using walrus operator
138
+ if network := _parse_cidr_pattern(pattern):
139
+ if host_ip and host_ip in network:
140
+ return True
141
+ continue
142
+
143
+ # For non-CIDR patterns, use existing domain matching logic
144
+ if _is_same_domain(host, pattern):
145
+ return True
146
+
147
+ return False
plain/http/request.py CHANGED
@@ -27,12 +27,9 @@ from plain.utils.datastructures import (
27
27
  MultiValueDict,
28
28
  )
29
29
  from plain.utils.encoding import iri_to_uri
30
- from plain.utils.http import is_same_domain, parse_header_parameters
31
- from plain.utils.regex_helper import _lazy_re_compile
30
+ from plain.utils.http import parse_header_parameters
32
31
 
33
- host_validation_re = _lazy_re_compile(
34
- r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
35
- )
32
+ from .hosts import split_domain_port, validate_host
36
33
 
37
34
 
38
35
  class UnreadablePostError(OSError):
@@ -702,47 +699,5 @@ def bytes_to_text(s, encoding):
702
699
  return s
703
700
 
704
701
 
705
- def split_domain_port(host):
706
- """
707
- Return a (domain, port) tuple from a given host.
708
-
709
- Returned domain is lowercased. If the host is invalid, the domain will be
710
- empty.
711
- """
712
- host = host.lower()
713
-
714
- if not host_validation_re.match(host):
715
- return "", ""
716
-
717
- if host[-1] == "]":
718
- # It's an IPv6 address without a port.
719
- return host, ""
720
- bits = host.rsplit(":", 1)
721
- domain, port = bits if len(bits) == 2 else (bits[0], "")
722
- # Remove a trailing dot (if present) from the domain.
723
- domain = domain.removesuffix(".")
724
- return domain, port
725
-
726
-
727
- def validate_host(host, allowed_hosts):
728
- """
729
- Validate the given host for this site.
730
-
731
- Check that the host looks valid and matches a host or host pattern in the
732
- given list of ``allowed_hosts``. Any pattern beginning with a period
733
- matches a domain and all its subdomains (e.g. ``.example.com`` matches
734
- ``example.com`` and any subdomain), ``*`` matches anything, and anything
735
- else must match exactly.
736
-
737
- Note: This function assumes that the given host is lowercased and has
738
- already had the port, if any, stripped off.
739
-
740
- Return ``True`` for a valid host, ``False`` otherwise.
741
- """
742
- return any(
743
- pattern == "*" or is_same_domain(host, pattern) for pattern in allowed_hosts
744
- )
745
-
746
-
747
702
  def parse_accept_header(header):
748
703
  return [MediaType(token) for token in header.split(",") if token.strip()]
@@ -24,6 +24,7 @@ URLS_ROUTER: str
24
24
 
25
25
  # Hosts/domain names that are valid for this site.
26
26
  # "*" matches anything, ".example.com" matches example.com and all subdomains
27
+ # "192.168.1.0/24" matches IP addresses in that CIDR range
27
28
  ALLOWED_HOSTS: list[str] = []
28
29
 
29
30
  # Default headers for all responses.
plain/signing.py CHANGED
@@ -35,12 +35,14 @@ These functions make use of all of them.
35
35
 
36
36
  import base64
37
37
  import datetime
38
+ import hmac
38
39
  import json
39
40
  import time
40
41
  import zlib
41
42
 
42
43
  from plain.runtime import settings
43
- from plain.utils.crypto import constant_time_compare, salted_hmac
44
+ from plain.utils.crypto import salted_hmac
45
+ from plain.utils.encoding import force_bytes
44
46
  from plain.utils.regex_helper import _lazy_re_compile
45
47
 
46
48
  _SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$")
@@ -196,7 +198,9 @@ class Signer:
196
198
  raise BadSignature(f'No "{self.sep}" found in value')
197
199
  value, sig = signed_value.rsplit(self.sep, 1)
198
200
  for key in [self.key, *self.fallback_keys]:
199
- if constant_time_compare(sig, self.signature(value, key)):
201
+ if hmac.compare_digest(
202
+ force_bytes(sig), force_bytes(self.signature(value, key))
203
+ ):
200
204
  return value
201
205
  raise BadSignature(f'Signature "{sig}" does not match')
202
206
 
plain/utils/crypto.py CHANGED
@@ -62,11 +62,6 @@ def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
62
62
  return "".join(secrets.choice(allowed_chars) for i in range(length))
63
63
 
64
64
 
65
- def constant_time_compare(val1, val2):
66
- """Return True if the two strings are equal, False otherwise."""
67
- return secrets.compare_digest(force_bytes(val1), force_bytes(val2))
68
-
69
-
70
65
  def pbkdf2(password, salt, iterations, dklen=0, digest=None):
71
66
  """Return the hash of password using pbkdf2."""
72
67
  if digest is None:
plain/utils/http.py CHANGED
@@ -92,26 +92,6 @@ def int_to_base36(i):
92
92
  return b36
93
93
 
94
94
 
95
- def is_same_domain(host, pattern):
96
- """
97
- Return ``True`` if the host is either an exact match or a match
98
- to the wildcard pattern.
99
-
100
- Any pattern beginning with a period matches a domain and all of its
101
- subdomains. (e.g. ``.example.com`` matches ``example.com`` and
102
- ``foo.example.com``). Anything else is an exact string match.
103
- """
104
- if not pattern:
105
- return False
106
-
107
- pattern = pattern.lower()
108
- return (
109
- pattern[0] == "."
110
- and (host.endswith(pattern) or host == pattern[1:])
111
- or pattern == host
112
- )
113
-
114
-
115
95
  def escape_leading_slashes(url):
116
96
  """
117
97
  If redirecting to an absolute path (two leading slashes), a slash must be
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.63.0
3
+ Version: 0.65.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,12 +1,12 @@
1
1
  plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
2
- plain/CHANGELOG.md,sha256=58av1OUbgw-UIYjVJuwoniXmtAD9AgJYII6tWVhmOtc,13821
2
+ plain/CHANGELOG.md,sha256=VksQqN6aUmaBpAwFEeYBcjbOSWR-LwwWHB8kJmqK3fU,14815
3
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
6
6
  plain/exceptions.py,sha256=ljOLqgVwPKlGWqaNmjHcHQf6y053bZ9ogkLFEGcs-Gg,5973
7
7
  plain/json.py,sha256=McJdsbMT1sYwkGRG--f2NSZz0hVXPMix9x3nKaaak2o,1262
8
8
  plain/paginator.py,sha256=iXiOyt2r_YwNrkqCRlaU7V-M_BKaaQ8XZElUBVa6yeU,5844
9
- plain/signing.py,sha256=r2KvCOxkrSWCULFxYa9BHYx3L3a2oLq8RDnq_92inTw,8207
9
+ plain/signing.py,sha256=i8Bf12c96u_1BZYjETiixhsLAWMAt_y4CIYZOsI6IVA,8295
10
10
  plain/validators.py,sha256=TePzFHzwR4JXUAZ_Y2vC6mkKgVxHX3QBXI6Oex0rV8c,19236
11
11
  plain/wsgi.py,sha256=R6k5FiAElvGDApEbMPTT0MPqSD7n2e2Az5chQqJZU0I,236
12
12
  plain/assets/README.md,sha256=iRZHHoXZCEFooXoDYSomF12XTcDFl7oNbRHpAFwxTM8,4349
@@ -26,7 +26,7 @@ plain/cli/changelog.py,sha256=j-k1yZk9mpm-fLZgeWastiyIisxNSuAJfXTQ2B6WQmk,3457
26
26
  plain/cli/chores.py,sha256=xXSSFvr8T5jWfLWqe6E8YVMw1BkQxyOHHVuY0x9RH0A,2412
27
27
  plain/cli/core.py,sha256=g0D1OZkYGWt05-V1oDNzX2wcoCIAxrZjlgaQs2qWLlc,3106
28
28
  plain/cli/docs.py,sha256=YEEE-Th1CSxiL-wj5fF-ZagqkqAZYkEPRMO1OYUsQrU,1066
29
- plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
29
+ plain/cli/formatting.py,sha256=JaOiIOxIQpCRgMNGw9xpnU_fMf6hROl7o8lA5aX114w,2580
30
30
  plain/cli/install.py,sha256=mffSYBmSJSj44OPBfu53nBQoyoz4jk69DvppubIB0mU,2791
31
31
  plain/cli/output.py,sha256=Fe3xS6Va4Bi1ZNrqi0nh09THTsdCyMW2b9SPY5I4n-o,1318
32
32
  plain/cli/preflight.py,sha256=8tHBD4L4nPLUKThfaYx3SUZSJzC48oV2m_Hbn6W4ODc,4124
@@ -36,7 +36,7 @@ plain/cli/scaffold.py,sha256=mcywA9DzfwoBSqWl5-Zpgcy1mTNUGEgdvoxXUrGcEVk,1351
36
36
  plain/cli/settings.py,sha256=9cx4bue664I2P7kUedlf4YhCPB0tSKSE4Q8mGyzEv2o,1995
37
37
  plain/cli/shell.py,sha256=PMHdwcRv48qXDToeq82aZaNth-cKc3V2pQ1yISrNMvY,1802
38
38
  plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
39
- plain/cli/upgrade.py,sha256=T8u81rA2_dSfJaK4vF1_OPkQpspBVWnlPxatyk_mdx0,5632
39
+ plain/cli/upgrade.py,sha256=0k33jNKvF_FSBcWNq_yEUgidGWrWlOURNfV8EaGE75U,5555
40
40
  plain/cli/urls.py,sha256=ghCW36aRszxmTo06A50FIvYopb6kQ07QekkDzM6_A1o,3824
41
41
  plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
42
42
  plain/cli/agent/__init__.py,sha256=Ipp65kuIF14TVxNqsj71MsWUePaKHUcdP3QmaYyNcg0,480
@@ -57,8 +57,9 @@ plain/forms/forms.py,sha256=hF7Dl8rEaiBTZhFQyfbh1Zf54BSEka8RYpBiGqkTa8I,10441
57
57
  plain/http/README.md,sha256=hkjTJJ2_WEGm7vaIxjjNHrzD6EN5VI6pjDJPIR9l1jo,760
58
58
  plain/http/__init__.py,sha256=PfmXBIq7onLO_bbOrVdj5rJeHxqJMYqrIobVKupUzUA,1003
59
59
  plain/http/cookie.py,sha256=THd7nOl-2ugeBPKgOhbD87aM2oxUbNH8HWrarUn0fpM,1955
60
+ plain/http/hosts.py,sha256=GbvFzle2VgPC2ehtXW8MM_1XD9gKuv7h-8HTOX3RiJA,4567
60
61
  plain/http/multipartparser.py,sha256=Z1dFJNAd8N5RHUuF67jh1jBfZOFepORsre_3ee6CgOQ,27266
61
- plain/http/request.py,sha256=93b2gqkfEsBczUyP_9vlueVoxyzzfbnJ423PDAk8aHc,26103
62
+ plain/http/request.py,sha256=ikDCo5WOSKgHvDz8jn_CaFNa8grVxhWrR60lROLjRwI,24672
62
63
  plain/http/response.py,sha256=FM3otFkKEEkAaV_pVB3JUSMhMk7x_zf5JcDWKhOKviM,23223
63
64
  plain/internal/__init__.py,sha256=fVBaYLCXEQc-7riHMSlw3vMTTuF7-0Bj2I8aGzv0o0w,171
64
65
  plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
@@ -97,7 +98,7 @@ plain/preflight/security.py,sha256=oxUZBp2M0bpBfUoLYepIxoex2Y90nyjlrL8XU8UTHYY,2
97
98
  plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
98
99
  plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
99
100
  plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
100
- plain/runtime/global_settings.py,sha256=PjgrsTQc3aQ0YxbZ43Lj2eNrOcP6hf4jBjjQ2lT0MfE,5767
101
+ plain/runtime/global_settings.py,sha256=GxcXFjXui5GLkiLlWe8P8X91ndShnmuJK0Ql6xjn24s,5826
101
102
  plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
102
103
  plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
103
104
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
@@ -130,7 +131,7 @@ plain/urls/utils.py,sha256=lKxTX_A3XJpIH7FjlNYju108stY6-8Sw2uVdiSsxOKQ,1249
130
131
  plain/utils/README.md,sha256=3RTH9t4SBLkYLMlnhpWwp72IjgQhOjYYxpT1gHwRUdE,232
131
132
  plain/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
132
133
  plain/utils/cache.py,sha256=iuvOTIfI1s857iVOAPNLK5lkzlrl0fIiBYaiUXWQu40,5303
133
- plain/utils/crypto.py,sha256=zFDydnaqNMGYFHUc-CAn8f93685a17BhGusAcITH1lI,2662
134
+ plain/utils/crypto.py,sha256=kONJB9aFFDHnbFixfigJkzaU-qBAtktCEzbhoHdodoA,2480
134
135
  plain/utils/datastructures.py,sha256=g4UYTbxIb_n8F9JWMP4dHPwUz71591fHreGATPO4qEc,10240
135
136
  plain/utils/dateparse.py,sha256=u9_tF85YteXSjW9KQzNg_pcCEFDZS3EGorCddcWU0vE,5351
136
137
  plain/utils/deconstruct.py,sha256=7NwEFIDCiadAArUBFmiErzDgfIgDWeKqqQFDXwSgQoQ,1830
@@ -140,7 +141,7 @@ plain/utils/encoding.py,sha256=T0Shb2xRAR3NPwwoqhpUOB55gDprWzqu72aRiiulv9Y,4251
140
141
  plain/utils/functional.py,sha256=eJksrhVdkC8HKF56qtVyTOsOnkZB2jMUnXSTGzjJMF4,13331
141
142
  plain/utils/hashable.py,sha256=uLWobCCh7VcEPJ7xzVGPgigNVuTazYJbyzRzHTCI_wo,739
142
143
  plain/utils/html.py,sha256=SR8oNrungB5gxJaHbvAaCw_bAiqLQOk09fj-iIXY0i0,3679
143
- plain/utils/http.py,sha256=VOOnwRXnDp5PL_qEmkInLTm10fF58vlhVjeSTdzV2cQ,6031
144
+ plain/utils/http.py,sha256=_YrXfauKOiEDr2beFK4UY2A2Am1Xz1BpZCho1A6I3W4,5471
144
145
  plain/utils/inspect.py,sha256=O3VMH5f4aGOrVpXJBKtQOxx01XrKnjjz6VO_MCV0xkE,1140
145
146
  plain/utils/ipv6.py,sha256=pISQ2AIlG8xXlxpphn388q03fq-fOrlu4GZR0YYjQXw,1267
146
147
  plain/utils/itercompat.py,sha256=lacIDjczhxbwG4ON_KfG1H6VNPOGOpbRhnVhbedo2CY,184
@@ -160,8 +161,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
160
161
  plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
161
162
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
162
163
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
163
- plain-0.63.0.dist-info/METADATA,sha256=ndV-xFNXqqO_AvIMwNJXShZ5EgbN5Al9i7MbtiGiDWI,4488
164
- plain-0.63.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
165
- plain-0.63.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
166
- plain-0.63.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
167
- plain-0.63.0.dist-info/RECORD,,
164
+ plain-0.65.0.dist-info/METADATA,sha256=L7dFhqvEh4ZVoSDn3ow2O751k6Rx4R_BCWv4I7__ZJw,4488
165
+ plain-0.65.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
+ plain-0.65.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
+ plain-0.65.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
+ plain-0.65.0.dist-info/RECORD,,
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  plain = plain.cli.core:cli
3
+ plain-build = plain.cli.build:build
3
4
  plain-changelog = plain.cli.changelog:changelog
File without changes