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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from dotguard.cli import main
4
+
5
+ sys.exit(main())
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
+ }