dotguard-cli 0.2.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.
- dotguard/__init__.py +22 -0
- dotguard/__main__.py +5 -0
- dotguard/checker.py +203 -0
- dotguard/cli.py +182 -0
- dotguard/differ.py +59 -0
- dotguard/models.py +63 -0
- dotguard/parser.py +216 -0
- dotguard/report.py +79 -0
- dotguard/secrets.py +90 -0
- dotguard/types.py +76 -0
- dotguard_cli-0.2.0.dist-info/METADATA +361 -0
- dotguard_cli-0.2.0.dist-info/RECORD +16 -0
- dotguard_cli-0.2.0.dist-info/WHEEL +5 -0
- dotguard_cli-0.2.0.dist-info/entry_points.txt +2 -0
- dotguard_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- dotguard_cli-0.2.0.dist-info/top_level.txt +1 -0
dotguard/parser.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""A small, dependency-free parser for .env files.
|
|
2
|
+
|
|
3
|
+
Supported syntax:
|
|
4
|
+
|
|
5
|
+
# a comment
|
|
6
|
+
KEY=value
|
|
7
|
+
export KEY=value
|
|
8
|
+
KEY="double quoted with \\n escapes" # trailing comment
|
|
9
|
+
KEY='single quoted, taken literally'
|
|
10
|
+
KEY=value # trailing comment (needs a space before #)
|
|
11
|
+
KEY="a quoted value
|
|
12
|
+
can span several lines"
|
|
13
|
+
URL=http://${HOST}:${PORT:-8080}/ # references to earlier keys
|
|
14
|
+
|
|
15
|
+
Annotation comments directly above a key attach metadata to it:
|
|
16
|
+
|
|
17
|
+
# @type port
|
|
18
|
+
# @required
|
|
19
|
+
PORT=8080
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from dotguard.models import Entry, EnvFile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParseError(ValueError):
|
|
30
|
+
def __init__(self, message: str, path: str, line: int) -> None:
|
|
31
|
+
super().__init__(f"{path}:{line}: {message}")
|
|
32
|
+
self.path = path
|
|
33
|
+
self.line = line
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
|
37
|
+
_ANNOTATION_RE = re.compile(r"^@([a-z][a-z0-9_-]*)(?:\s+(.*))?$")
|
|
38
|
+
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\", "$": "$"}
|
|
39
|
+
# ${NAME}, ${NAME:-default}, ${NAME-default} or $NAME
|
|
40
|
+
_REFERENCE_RE = re.compile(
|
|
41
|
+
r"\$(?:\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?:(?P<op>:?-)(?P<default>[^}]*))?\}"
|
|
42
|
+
r"|(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_file(path: str, interpolate: bool = True) -> EnvFile:
|
|
47
|
+
with open(path, encoding="utf-8") as handle:
|
|
48
|
+
return parse(handle.read(), path=path, interpolate=interpolate)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse(text: str, path: str = "<string>", interpolate: bool = True) -> EnvFile:
|
|
52
|
+
"""Parse ``text`` into an :class:`EnvFile`.
|
|
53
|
+
|
|
54
|
+
With ``interpolate``, ``$NAME`` and ``${NAME}`` in unquoted and
|
|
55
|
+
double-quoted values are replaced by the value of a key defined earlier in
|
|
56
|
+
the same file. Names that are not defined expand to an empty string and are
|
|
57
|
+
recorded in ``Entry.unresolved``.
|
|
58
|
+
"""
|
|
59
|
+
env = EnvFile(path=path)
|
|
60
|
+
annotations: dict[str, str] = {}
|
|
61
|
+
doc: list[str] = []
|
|
62
|
+
lines = text.splitlines()
|
|
63
|
+
index = 0
|
|
64
|
+
|
|
65
|
+
while index < len(lines):
|
|
66
|
+
number = index + 1
|
|
67
|
+
line = lines[index].strip()
|
|
68
|
+
index += 1
|
|
69
|
+
|
|
70
|
+
if not line:
|
|
71
|
+
# A blank line detaches comments from the next key.
|
|
72
|
+
annotations, doc = {}, []
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if line.startswith("#"):
|
|
76
|
+
comment = line[1:].strip()
|
|
77
|
+
match = _ANNOTATION_RE.match(comment)
|
|
78
|
+
if match:
|
|
79
|
+
annotations[match.group(1)] = (match.group(2) or "").strip()
|
|
80
|
+
else:
|
|
81
|
+
doc.append(comment)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
key, rest = _split_assignment(line, path, number)
|
|
85
|
+
|
|
86
|
+
# A quoted value may continue on the following lines until its
|
|
87
|
+
# closing quote.
|
|
88
|
+
if rest[:1] in ('"', "'"):
|
|
89
|
+
while _find_closing_quote(rest, rest[0]) is None and index < len(lines):
|
|
90
|
+
rest += "\n" + lines[index]
|
|
91
|
+
index += 1
|
|
92
|
+
|
|
93
|
+
known = {entry.key: entry.value for entry in env.entries} if interpolate else None
|
|
94
|
+
unresolved: list[str] = []
|
|
95
|
+
value = _parse_value(rest, path, number, known, unresolved)
|
|
96
|
+
|
|
97
|
+
if env.get(key) is not None:
|
|
98
|
+
raise ParseError(f"duplicate key {key!r}", path, number)
|
|
99
|
+
env.entries.append(
|
|
100
|
+
Entry(
|
|
101
|
+
key=key,
|
|
102
|
+
value=value,
|
|
103
|
+
line=number,
|
|
104
|
+
annotations=annotations,
|
|
105
|
+
doc=doc,
|
|
106
|
+
unresolved=unresolved,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
annotations, doc = {}, []
|
|
110
|
+
|
|
111
|
+
return env
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _split_assignment(line: str, path: str, number: int) -> tuple[str, str]:
|
|
115
|
+
if line.startswith("export "):
|
|
116
|
+
line = line[len("export "):].lstrip()
|
|
117
|
+
|
|
118
|
+
if "=" not in line:
|
|
119
|
+
raise ParseError("expected KEY=value", path, number)
|
|
120
|
+
|
|
121
|
+
key, rest = line.split("=", 1)
|
|
122
|
+
key = key.strip()
|
|
123
|
+
if not _KEY_RE.match(key):
|
|
124
|
+
raise ParseError(f"invalid key {key!r}", path, number)
|
|
125
|
+
return key, rest.strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_value(
|
|
129
|
+
rest: str,
|
|
130
|
+
path: str,
|
|
131
|
+
number: int,
|
|
132
|
+
known: dict[str, str] | None,
|
|
133
|
+
unresolved: list[str],
|
|
134
|
+
) -> str:
|
|
135
|
+
if rest.startswith("'"):
|
|
136
|
+
# Single quotes: everything is literal, no escapes, no references.
|
|
137
|
+
end = _find_closing_quote(rest, "'")
|
|
138
|
+
if end is None:
|
|
139
|
+
raise ParseError("unterminated quoted value", path, number)
|
|
140
|
+
value, tail = rest[1:end], rest[end + 1:]
|
|
141
|
+
elif rest.startswith('"'):
|
|
142
|
+
end = _find_closing_quote(rest, '"')
|
|
143
|
+
if end is None:
|
|
144
|
+
raise ParseError("unterminated quoted value", path, number)
|
|
145
|
+
value = _unescape(rest[1:end], known, unresolved)
|
|
146
|
+
tail = rest[end + 1:]
|
|
147
|
+
else:
|
|
148
|
+
value = _unescape(_strip_inline_comment(rest), known, unresolved, escapes={"$": "$"})
|
|
149
|
+
tail = ""
|
|
150
|
+
|
|
151
|
+
if tail and not tail.lstrip().startswith("#"):
|
|
152
|
+
raise ParseError("unexpected text after closing quote", path, number)
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _find_closing_quote(text: str, quote: str) -> int | None:
|
|
157
|
+
i = 1
|
|
158
|
+
while i < len(text):
|
|
159
|
+
char = text[i]
|
|
160
|
+
if char == "\\" and quote == '"':
|
|
161
|
+
i += 2
|
|
162
|
+
continue
|
|
163
|
+
if char == quote:
|
|
164
|
+
return i
|
|
165
|
+
i += 1
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _unescape(
|
|
170
|
+
value: str,
|
|
171
|
+
known: dict[str, str] | None,
|
|
172
|
+
unresolved: list[str],
|
|
173
|
+
escapes: dict[str, str] = _ESCAPES,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Apply backslash escapes and, when ``known`` is given, expand references."""
|
|
176
|
+
out = []
|
|
177
|
+
i = 0
|
|
178
|
+
while i < len(value):
|
|
179
|
+
char = value[i]
|
|
180
|
+
if char == "\\" and i + 1 < len(value) and value[i + 1] in escapes:
|
|
181
|
+
out.append(escapes[value[i + 1]])
|
|
182
|
+
i += 2
|
|
183
|
+
continue
|
|
184
|
+
if char == "$" and known is not None:
|
|
185
|
+
match = _REFERENCE_RE.match(value, i)
|
|
186
|
+
if match:
|
|
187
|
+
out.append(_resolve(match, known, unresolved))
|
|
188
|
+
i = match.end()
|
|
189
|
+
continue
|
|
190
|
+
out.append(char)
|
|
191
|
+
i += 1
|
|
192
|
+
return "".join(out)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolve(match: re.Match[str], known: dict[str, str], unresolved: list[str]) -> str:
|
|
196
|
+
name = match.group("braced") or match.group("bare")
|
|
197
|
+
op, default = match.group("op"), match.group("default")
|
|
198
|
+
value = known.get(name)
|
|
199
|
+
|
|
200
|
+
if op == ":-" and not value:
|
|
201
|
+
return default
|
|
202
|
+
if op == "-" and value is None:
|
|
203
|
+
return default
|
|
204
|
+
if value is None:
|
|
205
|
+
if name not in unresolved:
|
|
206
|
+
unresolved.append(name)
|
|
207
|
+
return ""
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _strip_inline_comment(value: str) -> str:
|
|
212
|
+
# "a#b" stays as-is (common in URLs); only " #" starts a comment.
|
|
213
|
+
match = re.search(r"\s#", value)
|
|
214
|
+
if match:
|
|
215
|
+
value = value[: match.start()]
|
|
216
|
+
return value.strip()
|
dotguard/report.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Render issues for people (text) and for tools (JSON, GitHub annotations)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from dotguard.models import Issue, Severity
|
|
11
|
+
|
|
12
|
+
FORMATS = ("text", "json", "github")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _use_color(stream: TextIO) -> bool:
|
|
16
|
+
if os.environ.get("NO_COLOR"):
|
|
17
|
+
return False
|
|
18
|
+
if os.environ.get("FORCE_COLOR"):
|
|
19
|
+
return True
|
|
20
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_mark(stream: TextIO) -> str:
|
|
24
|
+
# Windows consoles often use a legacy code page that cannot encode "✓".
|
|
25
|
+
try:
|
|
26
|
+
"✓".encode(getattr(stream, "encoding", None) or "utf-8")
|
|
27
|
+
except (UnicodeEncodeError, LookupError):
|
|
28
|
+
return "OK:"
|
|
29
|
+
return "✓"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _paint(text: str, code: str, enabled: bool) -> str:
|
|
33
|
+
return f"\033[{code}m{text}\033[0m" if enabled else text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(issues: list[Issue], fmt: str = "text", stream: TextIO | None = None) -> None:
|
|
37
|
+
# Resolve stdout at call time so redirection (and tests) work.
|
|
38
|
+
stream = stream or sys.stdout
|
|
39
|
+
if fmt == "json":
|
|
40
|
+
json.dump({"issues": [i.to_dict() for i in issues], "summary": summary(issues)}, stream, indent=2)
|
|
41
|
+
stream.write("\n")
|
|
42
|
+
elif fmt == "github":
|
|
43
|
+
_render_github(issues, stream)
|
|
44
|
+
else:
|
|
45
|
+
_render_text(issues, stream)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def summary(issues: list[Issue]) -> dict:
|
|
49
|
+
errors = sum(1 for i in issues if i.severity is Severity.ERROR)
|
|
50
|
+
return {"errors": errors, "warnings": len(issues) - errors}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render_text(issues: list[Issue], stream: TextIO) -> None:
|
|
54
|
+
color = _use_color(stream)
|
|
55
|
+
for item in issues:
|
|
56
|
+
location = item.path if item.line is None else f"{item.path}:{item.line}"
|
|
57
|
+
if item.severity is Severity.ERROR:
|
|
58
|
+
label = _paint("error", "1;31", color)
|
|
59
|
+
else:
|
|
60
|
+
label = _paint("warning", "1;33", color)
|
|
61
|
+
stream.write(f"{_paint(location, '2', color)} {label} {item.code} {item.message}\n")
|
|
62
|
+
|
|
63
|
+
counts = summary(issues)
|
|
64
|
+
if not issues:
|
|
65
|
+
stream.write(_paint(f"{_check_mark(stream)} no problems found", "32", color) + "\n")
|
|
66
|
+
else:
|
|
67
|
+
stream.write(f"\n{counts['errors']} error(s), {counts['warnings']} warning(s)\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_github(issues: list[Issue], stream: TextIO) -> None:
|
|
71
|
+
# https://docs.github.com/actions/reference/workflow-commands-for-github-actions
|
|
72
|
+
for item in issues:
|
|
73
|
+
level = "error" if item.severity is Severity.ERROR else "warning"
|
|
74
|
+
props = f"file={item.path}"
|
|
75
|
+
if item.line is not None:
|
|
76
|
+
props += f",line={item.line}"
|
|
77
|
+
props += f",title=dotguard {item.code}"
|
|
78
|
+
message = item.message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
79
|
+
stream.write(f"::{level} {props}::{message}\n")
|
dotguard/secrets.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Find real-looking secrets in files that are meant to be committed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
from dotguard.models import EnvFile, Issue, Severity
|
|
10
|
+
|
|
11
|
+
# Well-known credential formats. Each match is an error: these are never
|
|
12
|
+
# placeholders.
|
|
13
|
+
KNOWN_TOKENS: list[tuple[str, re.Pattern[str]]] = [
|
|
14
|
+
("AWS access key ID", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
15
|
+
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
|
|
16
|
+
("GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{60,}\b")),
|
|
17
|
+
("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
|
|
18
|
+
("Stripe live key", re.compile(r"\b[sr]k_live_[A-Za-z0-9]{16,}\b")),
|
|
19
|
+
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
|
|
20
|
+
("OpenAI-style API key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b")),
|
|
21
|
+
("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
22
|
+
("JSON Web Token", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}")),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
SENSITIVE_NAME = re.compile(
|
|
26
|
+
r"(SECRET|TOKEN|PASSWORD|PASSWD|PWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|AUTH)",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Values that are obviously documentation, not real credentials.
|
|
31
|
+
PLACEHOLDER = re.compile(
|
|
32
|
+
r"^(?:|changeme|change[-_]me|replace[-_]?me|todo|secret|password|example.*|your[-_].*|"
|
|
33
|
+
r"<.*>|\{\{.*\}\}|\$\{.*\}|x+|\*+|\.\.\.|null|none|test|dummy.*|fake.*|placeholder.*)$",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
ENTROPY_THRESHOLD = 3.5
|
|
38
|
+
MIN_SECRET_LENGTH = 16
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def shannon_entropy(value: str) -> float:
|
|
42
|
+
if not value:
|
|
43
|
+
return 0.0
|
|
44
|
+
counts = Counter(value)
|
|
45
|
+
total = len(value)
|
|
46
|
+
return -sum((n / total) * math.log2(n / total) for n in counts.values())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan(env: EnvFile) -> list[Issue]:
|
|
50
|
+
"""Report values in ``env`` that look like real credentials."""
|
|
51
|
+
issues: list[Issue] = []
|
|
52
|
+
|
|
53
|
+
for entry in env.entries:
|
|
54
|
+
value = entry.value
|
|
55
|
+
|
|
56
|
+
known = next((name for name, regex in KNOWN_TOKENS if regex.search(value)), None)
|
|
57
|
+
if known:
|
|
58
|
+
issues.append(
|
|
59
|
+
Issue(
|
|
60
|
+
"DS001",
|
|
61
|
+
f"{entry.key} contains what looks like a real {known}",
|
|
62
|
+
Severity.ERROR,
|
|
63
|
+
env.path,
|
|
64
|
+
entry.line,
|
|
65
|
+
entry.key,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if PLACEHOLDER.match(value):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
looks_sensitive = SENSITIVE_NAME.search(entry.key) or "secret" in entry.annotations
|
|
74
|
+
if (
|
|
75
|
+
looks_sensitive
|
|
76
|
+
and len(value) >= MIN_SECRET_LENGTH
|
|
77
|
+
and shannon_entropy(value) >= ENTROPY_THRESHOLD
|
|
78
|
+
):
|
|
79
|
+
issues.append(
|
|
80
|
+
Issue(
|
|
81
|
+
"DS002",
|
|
82
|
+
f"{entry.key} has a high-entropy value; use a placeholder in shared files",
|
|
83
|
+
Severity.WARNING,
|
|
84
|
+
env.path,
|
|
85
|
+
entry.line,
|
|
86
|
+
entry.key,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return issues
|
dotguard/types.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Value validators used by the ``@type`` annotation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
# A validator returns None when the value is valid, or a short reason otherwise.
|
|
10
|
+
Validator = Callable[[str], Optional[str]]
|
|
11
|
+
|
|
12
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
13
|
+
_FALSE = {"0", "false", "no", "off"}
|
|
14
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _string(value: str) -> str | None:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _int(value: str) -> str | None:
|
|
22
|
+
try:
|
|
23
|
+
int(value, 10)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return "expected an integer"
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _float(value: str) -> str | None:
|
|
30
|
+
try:
|
|
31
|
+
float(value)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return "expected a number"
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bool(value: str) -> str | None:
|
|
38
|
+
if value.lower() in _TRUE | _FALSE:
|
|
39
|
+
return None
|
|
40
|
+
return "expected a boolean (true/false, yes/no, on/off, 1/0)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _port(value: str) -> str | None:
|
|
44
|
+
if _int(value) is not None or not 1 <= int(value) <= 65535:
|
|
45
|
+
return "expected a port number between 1 and 65535"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _url(value: str) -> str | None:
|
|
50
|
+
parsed = urlparse(value)
|
|
51
|
+
if parsed.scheme and parsed.netloc:
|
|
52
|
+
return None
|
|
53
|
+
return "expected a URL with a scheme and host, e.g. https://example.com"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _email(value: str) -> str | None:
|
|
57
|
+
if _EMAIL_RE.match(value):
|
|
58
|
+
return None
|
|
59
|
+
return "expected an email address"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
VALIDATORS: dict[str, Validator] = {
|
|
63
|
+
"string": _string,
|
|
64
|
+
"str": _string,
|
|
65
|
+
"int": _int,
|
|
66
|
+
"integer": _int,
|
|
67
|
+
"float": _float,
|
|
68
|
+
"number": _float,
|
|
69
|
+
"bool": _bool,
|
|
70
|
+
"boolean": _bool,
|
|
71
|
+
"port": _port,
|
|
72
|
+
"url": _url,
|
|
73
|
+
"email": _email,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
NUMERIC_TYPES = {"int", "integer", "float", "number", "port"}
|