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/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""dotguard: validate .env files against a documented .env.example."""
|
|
2
|
+
|
|
3
|
+
from dotguard.checker import check
|
|
4
|
+
from dotguard.differ import diff
|
|
5
|
+
from dotguard.models import Entry, EnvFile, Issue, Severity
|
|
6
|
+
from dotguard.parser import parse, parse_file
|
|
7
|
+
from dotguard.secrets import scan
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Entry",
|
|
13
|
+
"EnvFile",
|
|
14
|
+
"Issue",
|
|
15
|
+
"Severity",
|
|
16
|
+
"check",
|
|
17
|
+
"diff",
|
|
18
|
+
"parse",
|
|
19
|
+
"parse_file",
|
|
20
|
+
"scan",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
dotguard/__main__.py
ADDED
dotguard/checker.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Compare a .env file with the .env.example that documents it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from dotguard.models import Entry, EnvFile, Issue, Severity
|
|
8
|
+
from dotguard.types import NUMERIC_TYPES, VALIDATORS
|
|
9
|
+
|
|
10
|
+
KNOWN_ANNOTATIONS = {
|
|
11
|
+
"type",
|
|
12
|
+
"required",
|
|
13
|
+
"optional",
|
|
14
|
+
"pattern",
|
|
15
|
+
"values",
|
|
16
|
+
"min",
|
|
17
|
+
"max",
|
|
18
|
+
"secret",
|
|
19
|
+
"deprecated",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check(env: EnvFile, example: EnvFile, strict: bool = False) -> list[Issue]:
|
|
24
|
+
"""Return every issue found in ``env`` according to ``example``.
|
|
25
|
+
|
|
26
|
+
``strict`` turns keys that exist only in ``env`` into errors instead of
|
|
27
|
+
warnings.
|
|
28
|
+
"""
|
|
29
|
+
issues: list[Issue] = []
|
|
30
|
+
|
|
31
|
+
for spec in example.entries:
|
|
32
|
+
issues.extend(_check_annotations(spec, example.path))
|
|
33
|
+
entry = env.get(spec.key)
|
|
34
|
+
|
|
35
|
+
if entry is None:
|
|
36
|
+
if "optional" not in spec.annotations:
|
|
37
|
+
issues.append(
|
|
38
|
+
Issue(
|
|
39
|
+
"DG001",
|
|
40
|
+
f"{spec.key} is missing (documented in {example.path}:{spec.line})",
|
|
41
|
+
Severity.ERROR,
|
|
42
|
+
env.path,
|
|
43
|
+
key=spec.key,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
if "deprecated" in spec.annotations:
|
|
49
|
+
reason = spec.annotations["deprecated"] or "it is deprecated"
|
|
50
|
+
issues.append(
|
|
51
|
+
Issue(
|
|
52
|
+
"DG008",
|
|
53
|
+
f"{spec.key} is deprecated: {reason}",
|
|
54
|
+
Severity.WARNING,
|
|
55
|
+
env.path,
|
|
56
|
+
entry.line,
|
|
57
|
+
spec.key,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
issues.extend(_check_value(entry, spec, env.path))
|
|
62
|
+
|
|
63
|
+
documented = set(example.keys())
|
|
64
|
+
for entry in env.entries:
|
|
65
|
+
for name in entry.unresolved:
|
|
66
|
+
issues.append(
|
|
67
|
+
Issue(
|
|
68
|
+
"DG011",
|
|
69
|
+
f"{entry.key} references ${{{name}}}, which is not defined above it",
|
|
70
|
+
Severity.WARNING,
|
|
71
|
+
env.path,
|
|
72
|
+
entry.line,
|
|
73
|
+
entry.key,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
if entry.key not in documented:
|
|
77
|
+
issues.append(
|
|
78
|
+
Issue(
|
|
79
|
+
"DG002",
|
|
80
|
+
f"{entry.key} is not documented in {example.path}",
|
|
81
|
+
Severity.ERROR if strict else Severity.WARNING,
|
|
82
|
+
env.path,
|
|
83
|
+
entry.line,
|
|
84
|
+
entry.key,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return issues
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_annotations(spec: Entry, path: str) -> list[Issue]:
|
|
92
|
+
issues = []
|
|
93
|
+
for name in spec.annotations:
|
|
94
|
+
if name not in KNOWN_ANNOTATIONS:
|
|
95
|
+
issues.append(
|
|
96
|
+
Issue(
|
|
97
|
+
"DG009",
|
|
98
|
+
f"unknown annotation @{name} on {spec.key}",
|
|
99
|
+
Severity.WARNING,
|
|
100
|
+
path,
|
|
101
|
+
spec.line,
|
|
102
|
+
spec.key,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
type_name = spec.annotations.get("type")
|
|
107
|
+
if type_name is not None and type_name not in VALIDATORS:
|
|
108
|
+
issues.append(
|
|
109
|
+
Issue(
|
|
110
|
+
"DG010",
|
|
111
|
+
f"unknown @type {type_name!r} on {spec.key}; "
|
|
112
|
+
f"choose one of: {', '.join(sorted(VALIDATORS))}",
|
|
113
|
+
Severity.ERROR,
|
|
114
|
+
path,
|
|
115
|
+
spec.line,
|
|
116
|
+
spec.key,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
pattern = spec.annotations.get("pattern")
|
|
121
|
+
if pattern is not None:
|
|
122
|
+
try:
|
|
123
|
+
re.compile(pattern)
|
|
124
|
+
except re.error as exc:
|
|
125
|
+
issues.append(
|
|
126
|
+
Issue(
|
|
127
|
+
"DG010",
|
|
128
|
+
f"invalid @pattern on {spec.key}: {exc}",
|
|
129
|
+
Severity.ERROR,
|
|
130
|
+
path,
|
|
131
|
+
spec.line,
|
|
132
|
+
spec.key,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return issues
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_value(entry: Entry, spec: Entry, path: str) -> list[Issue]:
|
|
139
|
+
rules = spec.annotations
|
|
140
|
+
value = entry.value
|
|
141
|
+
|
|
142
|
+
def issue(code: str, message: str) -> Issue:
|
|
143
|
+
return Issue(code, message, Severity.ERROR, path, entry.line, entry.key)
|
|
144
|
+
|
|
145
|
+
if value == "":
|
|
146
|
+
if "required" in rules:
|
|
147
|
+
return [issue("DG003", f"{entry.key} is required but empty")]
|
|
148
|
+
# An empty optional value has nothing else to validate.
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
type_name = rules.get("type", "string")
|
|
152
|
+
validator = VALIDATORS.get(type_name)
|
|
153
|
+
if validator is not None:
|
|
154
|
+
reason = validator(value)
|
|
155
|
+
if reason:
|
|
156
|
+
return [issue("DG004", f"{entry.key}={_preview(value, rules)}: {reason}")]
|
|
157
|
+
|
|
158
|
+
issues = []
|
|
159
|
+
|
|
160
|
+
pattern = rules.get("pattern")
|
|
161
|
+
if pattern is not None:
|
|
162
|
+
try:
|
|
163
|
+
if not re.fullmatch(pattern, value):
|
|
164
|
+
issues.append(
|
|
165
|
+
issue("DG005", f"{entry.key} does not match pattern {pattern!r}")
|
|
166
|
+
)
|
|
167
|
+
except re.error:
|
|
168
|
+
pass # Already reported by _check_annotations.
|
|
169
|
+
|
|
170
|
+
allowed = rules.get("values")
|
|
171
|
+
if allowed:
|
|
172
|
+
choices = [choice.strip() for choice in allowed.split(",") if choice.strip()]
|
|
173
|
+
if value not in choices:
|
|
174
|
+
issues.append(
|
|
175
|
+
issue(
|
|
176
|
+
"DG007",
|
|
177
|
+
f"{entry.key}={_preview(value, rules)} is not one of: {', '.join(choices)}",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if type_name in NUMERIC_TYPES:
|
|
182
|
+
number = float(value)
|
|
183
|
+
for bound, compare, word in (("min", float.__lt__, "below"), ("max", float.__gt__, "above")):
|
|
184
|
+
limit = rules.get(bound)
|
|
185
|
+
if limit is None:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
limit_value = float(limit)
|
|
189
|
+
except ValueError:
|
|
190
|
+
continue
|
|
191
|
+
if compare(number, limit_value):
|
|
192
|
+
issues.append(
|
|
193
|
+
issue("DG006", f"{entry.key}={value} is {word} the @{bound} of {limit}")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return issues
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _preview(value: str, rules: dict) -> str:
|
|
200
|
+
"""Never echo a secret back to the terminal or a CI log."""
|
|
201
|
+
if "secret" in rules:
|
|
202
|
+
return "****"
|
|
203
|
+
return value if len(value) <= 40 else value[:37] + "..."
|
dotguard/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Command-line interface: ``dotguard check | scan | diff | init``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from dotguard import __version__
|
|
10
|
+
from dotguard.checker import check
|
|
11
|
+
from dotguard.differ import diff
|
|
12
|
+
from dotguard.models import EnvFile, Issue, Severity
|
|
13
|
+
from dotguard.parser import ParseError, parse_file
|
|
14
|
+
from dotguard.report import FORMATS, render
|
|
15
|
+
from dotguard.secrets import SENSITIVE_NAME, scan
|
|
16
|
+
from dotguard.types import VALIDATORS
|
|
17
|
+
|
|
18
|
+
EXIT_OK = 0
|
|
19
|
+
EXIT_PROBLEMS = 1
|
|
20
|
+
EXIT_USAGE = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="dotguard",
|
|
26
|
+
description="Validate .env files against a documented .env.example.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
30
|
+
sub.required = True
|
|
31
|
+
|
|
32
|
+
def add_output_options(p: argparse.ArgumentParser) -> None:
|
|
33
|
+
p.add_argument("--format", choices=FORMATS, default="text", help="output format (default: text)")
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--fail-on-warning",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="exit with status 1 on warnings too, not only on errors",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
p_check = sub.add_parser("check", help="check a .env file against .env.example")
|
|
41
|
+
p_check.add_argument("--env", default=".env", help="file to check (default: .env)")
|
|
42
|
+
p_check.add_argument(
|
|
43
|
+
"--example", default=".env.example", help="documented reference file (default: .env.example)"
|
|
44
|
+
)
|
|
45
|
+
p_check.add_argument(
|
|
46
|
+
"--strict", action="store_true", help="treat keys missing from the example as errors"
|
|
47
|
+
)
|
|
48
|
+
add_output_options(p_check)
|
|
49
|
+
|
|
50
|
+
p_scan = sub.add_parser("scan", help="look for real secrets in files you commit")
|
|
51
|
+
p_scan.add_argument(
|
|
52
|
+
"files", nargs="*", default=[".env.example"], help="files to scan (default: .env.example)"
|
|
53
|
+
)
|
|
54
|
+
add_output_options(p_scan)
|
|
55
|
+
|
|
56
|
+
p_diff = sub.add_parser("diff", help="compare the keys of two env files, e.g. staging and production")
|
|
57
|
+
p_diff.add_argument("left", help="first file, e.g. .env.staging")
|
|
58
|
+
p_diff.add_argument("right", help="second file, e.g. .env.production")
|
|
59
|
+
p_diff.add_argument(
|
|
60
|
+
"--values",
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="also report keys whose values differ (values themselves are never printed)",
|
|
63
|
+
)
|
|
64
|
+
add_output_options(p_diff)
|
|
65
|
+
|
|
66
|
+
p_init = sub.add_parser("init", help="create .env.example from an existing .env")
|
|
67
|
+
p_init.add_argument("--env", default=".env", help="source file (default: .env)")
|
|
68
|
+
p_init.add_argument("--output", default=".env.example", help="file to write (default: .env.example)")
|
|
69
|
+
p_init.add_argument(
|
|
70
|
+
"--keep-values",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="keep non-sensitive values as defaults instead of blanking every value",
|
|
73
|
+
)
|
|
74
|
+
p_init.add_argument("--force", action="store_true", help="overwrite the output file if it exists")
|
|
75
|
+
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
args = build_parser().parse_args(argv)
|
|
81
|
+
try:
|
|
82
|
+
if args.command == "check":
|
|
83
|
+
return _cmd_check(args)
|
|
84
|
+
if args.command == "scan":
|
|
85
|
+
return _cmd_scan(args)
|
|
86
|
+
if args.command == "diff":
|
|
87
|
+
return _cmd_diff(args)
|
|
88
|
+
return _cmd_init(args)
|
|
89
|
+
except ParseError as exc:
|
|
90
|
+
print(f"dotguard: parse error: {exc}", file=sys.stderr)
|
|
91
|
+
return EXIT_USAGE
|
|
92
|
+
except FileNotFoundError as exc:
|
|
93
|
+
print(f"dotguard: file not found: {exc.filename}", file=sys.stderr)
|
|
94
|
+
return EXIT_USAGE
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _exit_code(issues: list[Issue], fail_on_warning: bool) -> int:
|
|
98
|
+
if any(i.severity is Severity.ERROR for i in issues):
|
|
99
|
+
return EXIT_PROBLEMS
|
|
100
|
+
if fail_on_warning and issues:
|
|
101
|
+
return EXIT_PROBLEMS
|
|
102
|
+
return EXIT_OK
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
106
|
+
issues = check(parse_file(args.env), parse_file(args.example), strict=args.strict)
|
|
107
|
+
render(issues, args.format)
|
|
108
|
+
return _exit_code(issues, args.fail_on_warning)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
112
|
+
issues: list[Issue] = []
|
|
113
|
+
for path in args.files:
|
|
114
|
+
issues.extend(scan(parse_file(path)))
|
|
115
|
+
render(issues, args.format)
|
|
116
|
+
return _exit_code(issues, args.fail_on_warning)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cmd_diff(args: argparse.Namespace) -> int:
|
|
120
|
+
issues = diff(parse_file(args.left), parse_file(args.right), compare_values=args.values)
|
|
121
|
+
render(issues, args.format)
|
|
122
|
+
return _exit_code(issues, args.fail_on_warning)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
126
|
+
if os.path.exists(args.output) and not args.force:
|
|
127
|
+
print(f"dotguard: {args.output} already exists (use --force to overwrite)", file=sys.stderr)
|
|
128
|
+
return EXIT_USAGE
|
|
129
|
+
|
|
130
|
+
source = parse_file(args.env)
|
|
131
|
+
with open(args.output, "w", encoding="utf-8") as handle:
|
|
132
|
+
handle.write(render_example(source, keep_values=args.keep_values))
|
|
133
|
+
print(f"wrote {args.output} with {len(source.entries)} key(s) from {args.env}")
|
|
134
|
+
return EXIT_OK
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_example(source: EnvFile, keep_values: bool = False) -> str:
|
|
138
|
+
"""Turn a real .env into a shareable, annotated .env.example."""
|
|
139
|
+
lines = [
|
|
140
|
+
"# Generated by dotguard. Document each key, then commit this file.",
|
|
141
|
+
"# Annotations: @type @required @optional @pattern @values @min @max @secret @deprecated",
|
|
142
|
+
"",
|
|
143
|
+
]
|
|
144
|
+
for entry in source.entries:
|
|
145
|
+
lines.extend(f"# {text}" for text in entry.doc)
|
|
146
|
+
for name, value in entry.annotations.items():
|
|
147
|
+
lines.append(f"# @{name} {value}".rstrip())
|
|
148
|
+
|
|
149
|
+
sensitive = bool(SENSITIVE_NAME.search(entry.key)) or "secret" in entry.annotations
|
|
150
|
+
if sensitive and "secret" not in entry.annotations:
|
|
151
|
+
lines.append("# @secret")
|
|
152
|
+
if "type" not in entry.annotations and entry.value:
|
|
153
|
+
guessed = _guess_type(entry.value)
|
|
154
|
+
if guessed != "string":
|
|
155
|
+
lines.append(f"# @type {guessed}")
|
|
156
|
+
|
|
157
|
+
value = entry.value if keep_values and not sensitive else ""
|
|
158
|
+
lines.append(f"{entry.key}={_quote(value)}")
|
|
159
|
+
lines.append("")
|
|
160
|
+
return "\n".join(lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _guess_type(value: str) -> str:
|
|
164
|
+
if value.lower() in {"true", "false", "yes", "no", "on", "off"}:
|
|
165
|
+
return "bool"
|
|
166
|
+
for name in ("port", "int", "float", "url", "email"):
|
|
167
|
+
if VALIDATORS[name](value) is None:
|
|
168
|
+
return name
|
|
169
|
+
return "string"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _quote(value: str) -> str:
|
|
173
|
+
if value == "" or all(c.isalnum() or c in "-_./:@,+" for c in value):
|
|
174
|
+
return value
|
|
175
|
+
escaped = (
|
|
176
|
+
value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("$", "\\$")
|
|
177
|
+
)
|
|
178
|
+
return f'"{escaped}"'
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
dotguard/differ.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Compare the keys of two environment files, e.g. staging and production."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dotguard.models import EnvFile, Issue, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def diff(left: EnvFile, right: EnvFile, compare_values: bool = False) -> list[Issue]:
|
|
9
|
+
"""Report keys that exist in only one file.
|
|
10
|
+
|
|
11
|
+
With ``compare_values``, also report keys whose values differ. Values are
|
|
12
|
+
never included in the messages, so the output is safe to share.
|
|
13
|
+
"""
|
|
14
|
+
issues: list[Issue] = []
|
|
15
|
+
right_keys = set(right.keys())
|
|
16
|
+
left_keys = set(left.keys())
|
|
17
|
+
|
|
18
|
+
for entry in left.entries:
|
|
19
|
+
if entry.key not in right_keys:
|
|
20
|
+
issues.append(
|
|
21
|
+
Issue(
|
|
22
|
+
"DD001",
|
|
23
|
+
f"{entry.key} is set in {left.path} but missing from {right.path}",
|
|
24
|
+
Severity.ERROR,
|
|
25
|
+
left.path,
|
|
26
|
+
entry.line,
|
|
27
|
+
entry.key,
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
for entry in right.entries:
|
|
32
|
+
if entry.key not in left_keys:
|
|
33
|
+
issues.append(
|
|
34
|
+
Issue(
|
|
35
|
+
"DD002",
|
|
36
|
+
f"{entry.key} is set in {right.path} but missing from {left.path}",
|
|
37
|
+
Severity.ERROR,
|
|
38
|
+
right.path,
|
|
39
|
+
entry.line,
|
|
40
|
+
entry.key,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if compare_values:
|
|
45
|
+
for entry in left.entries:
|
|
46
|
+
other = right.get(entry.key)
|
|
47
|
+
if other is not None and other.value != entry.value:
|
|
48
|
+
issues.append(
|
|
49
|
+
Issue(
|
|
50
|
+
"DD003",
|
|
51
|
+
f"{entry.key} has a different value in {right.path}:{other.line}",
|
|
52
|
+
Severity.WARNING,
|
|
53
|
+
left.path,
|
|
54
|
+
entry.line,
|
|
55
|
+
entry.key,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return issues
|
dotguard/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Data types shared across dotguard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(str, enum.Enum):
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
WARNING = "warning"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Entry:
|
|
16
|
+
"""A single KEY=value assignment in a .env file."""
|
|
17
|
+
|
|
18
|
+
key: str
|
|
19
|
+
value: str
|
|
20
|
+
line: int
|
|
21
|
+
# Annotations come from "# @name value" comments directly above the key.
|
|
22
|
+
annotations: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
# Plain comment lines above the key, used as human documentation.
|
|
24
|
+
doc: list[str] = field(default_factory=list)
|
|
25
|
+
# Names referenced as $NAME / ${NAME} that were not defined earlier.
|
|
26
|
+
unresolved: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EnvFile:
|
|
31
|
+
path: str
|
|
32
|
+
entries: list[Entry] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> Entry | None:
|
|
35
|
+
for entry in self.entries:
|
|
36
|
+
if entry.key == key:
|
|
37
|
+
return entry
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def keys(self) -> list[str]:
|
|
41
|
+
return [entry.key for entry in self.entries]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Issue:
|
|
46
|
+
"""A problem found while checking or scanning a file."""
|
|
47
|
+
|
|
48
|
+
code: str
|
|
49
|
+
message: str
|
|
50
|
+
severity: Severity
|
|
51
|
+
path: str
|
|
52
|
+
line: int | None = None
|
|
53
|
+
key: str | None = None
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, object]:
|
|
56
|
+
return {
|
|
57
|
+
"code": self.code,
|
|
58
|
+
"message": self.message,
|
|
59
|
+
"severity": self.severity.value,
|
|
60
|
+
"path": self.path,
|
|
61
|
+
"line": self.line,
|
|
62
|
+
"key": self.key,
|
|
63
|
+
}
|