scratchrelaxtv 0.7.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.
@@ -0,0 +1,552 @@
1
+ """scratchrelaxtv module.
2
+
3
+ scratchrelaxtv (anagram of extract-hcl-vars) is a Terraform module development
4
+ tool. Use it to:
5
+
6
+ * extract vars from HCL and generate an HCL variables file;
7
+ * generate a module-use stub file;
8
+ * delete files generated by scratchrelaxtv; and
9
+ * check whether a module declares every variable it uses and uses every
10
+ variable it declares.
11
+
12
+ By default, Terraform files are read from every ``*.tf`` file in the current
13
+ directory, so modules split across several files are handled as a whole.
14
+
15
+ Example:
16
+ Help using the scratchrelaxtv CLI can be found by typing the following::
17
+
18
+ $ scratchrelaxtv --help
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import functools
25
+ import glob
26
+ import logging
27
+ import os
28
+ import re
29
+
30
+ __version__ = "0.7.0"
31
+ EXIT_OKAY = 0
32
+ EXIT_NOT_OKAY = 1
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # HCL identifiers are Unicode: a letter or underscore, then letters, digits, "_", or "-".
37
+ _HEREDOC_START = re.compile(r"<<-?([^\W\d][\w-]*)[ \t]*\r?\n")
38
+ # Runs of characters that can't start a comment, string, heredoc, or brace.
39
+ _PLAIN_EXPR = re.compile(r'[^#/"<{}]+')
40
+ _PLAIN_QUOTED = re.compile(r'[^\\"\n$%]+')
41
+ _PLAIN_HEREDOC = re.compile(r"[^\n$%]+")
42
+
43
+
44
+ def remove_prefix(text: str, prefix: str) -> str:
45
+ """Remove prefix from a string."""
46
+ return text[text.startswith(prefix) and len(prefix) :]
47
+
48
+
49
+ def aligned(pairs: list[tuple[str, str]], indent: str = "") -> str:
50
+ """Return ``name = value`` lines with ``=`` aligned the way ``terraform fmt`` does."""
51
+ width = max((len(name) for name, _ in pairs), default=0)
52
+ return "".join(f"{indent}{name.ljust(width)} = {value}\n" for name, value in pairs)
53
+
54
+
55
+ def variable_block(name: str) -> str:
56
+ """Return a variable declaration with no default, so the input stays required."""
57
+ body = aligned([("description", '""'), ("type", "any")], indent=" ")
58
+ return f'variable "{name}" {{\n{body}}}\n'
59
+
60
+
61
+ def _blank(text: str) -> str:
62
+ """Replace every character except line breaks with a space."""
63
+ return "\n".join(" " * len(line) for line in text.split("\n"))
64
+
65
+
66
+ class _Scanner:
67
+ """Split HCL into comment-free code, its expressions, and its top-level parts.
68
+
69
+ Tracks quoted strings, heredocs, ``${...}``/``%{...}`` template sequences (which
70
+ may contain nested strings), and brace depth, so comment markers and
71
+ declaration-like text are only recognized where HCL treats them as such.
72
+ """
73
+
74
+ def __init__(self, text: str) -> None:
75
+ self.text = text
76
+ self.i = 0
77
+ self.code: list[str] = []
78
+ self.top: list[str] = []
79
+ self.expr: list[str] = []
80
+
81
+ def emit(self, text: str, top: bool, literal: bool = False) -> None:
82
+ """Add text; ``literal`` marks string or heredoc text that isn't an expression."""
83
+ self.code.append(text)
84
+ self.top.append(text if top else _blank(text))
85
+ self.expr.append(_blank(text) if literal else text)
86
+
87
+ def startswith(self, prefix: str) -> bool:
88
+ return self.text.startswith(prefix, self.i)
89
+
90
+ def expression(self, top: bool, in_template: bool) -> None:
91
+ """Scan expressions until EOF or, in a template sequence, its closing brace."""
92
+ text, depth = self.text, 0
93
+ while self.i < len(text):
94
+ char, here = text[self.i], top and depth == 0
95
+ if plain := _PLAIN_EXPR.match(text, self.i):
96
+ self.emit(plain[0], here)
97
+ self.i = plain.end()
98
+ elif char == "#" or self.startswith("//"):
99
+ newline = text.find("\n", self.i)
100
+ self.emit(" ", here)
101
+ self.i = len(text) if newline < 0 else newline
102
+ elif self.startswith("/*"):
103
+ close = text.find("*/", self.i + 2)
104
+ stop = len(text) if close < 0 else close + 2
105
+ self.emit(" " + "\n" * text.count("\n", self.i, stop), here)
106
+ self.i = stop
107
+ elif char == '"':
108
+ self.quoted(here)
109
+ elif self.startswith("<<") and (heredoc := _HEREDOC_START.match(text, self.i)):
110
+ self.heredoc(heredoc, here)
111
+ elif char == "{":
112
+ self.emit(char, here)
113
+ depth += 1
114
+ self.i += 1
115
+ elif char == "}":
116
+ self.i += 1
117
+ if depth == 0 and in_template:
118
+ self.emit(char, False)
119
+ return
120
+ depth = max(depth - 1, 0)
121
+ self.emit(char, top and depth == 0)
122
+ else:
123
+ self.emit(char, here)
124
+ self.i += 1
125
+
126
+ def template_part(self, top: bool) -> bool:
127
+ """Handle an escape or template sequence at the cursor; return True if one was found."""
128
+ if self.startswith("$${") or self.startswith("%%{"):
129
+ self.emit(self.text[self.i : self.i + 3], top, literal=True)
130
+ self.i += 3
131
+ elif self.startswith("${") or self.startswith("%{"):
132
+ self.emit(self.text[self.i : self.i + 2], top, literal=True)
133
+ self.i += 2
134
+ self.expression(top=False, in_template=True)
135
+ else:
136
+ return False
137
+ return True
138
+
139
+ def quoted(self, top: bool) -> None:
140
+ text = self.text
141
+ self.emit('"', top, literal=True)
142
+ self.i += 1
143
+ while self.i < len(text):
144
+ char = text[self.i]
145
+ if char == "\\":
146
+ self.emit(text[self.i : self.i + 2], top, literal=True)
147
+ self.i += 2
148
+ elif plain := _PLAIN_QUOTED.match(text, self.i):
149
+ self.emit(plain[0], top, literal=True)
150
+ self.i = plain.end()
151
+ elif char in '"\n':
152
+ if char == '"':
153
+ self.emit(char, top, literal=True)
154
+ self.i += 1
155
+ return
156
+ elif not self.template_part(top):
157
+ self.emit(char, top, literal=True)
158
+ self.i += 1
159
+
160
+ def heredoc(self, start: re.Match[str], top: bool) -> None:
161
+ text = self.text
162
+ closing = re.compile(rf"[ \t]*{re.escape(start[1])}[ \t]*(?=\r?\n|$)")
163
+ self.emit(start[0], top, literal=True)
164
+ self.i = start.end()
165
+ line_start = True
166
+ while self.i < len(text):
167
+ if line_start and (end := closing.match(text, self.i)):
168
+ self.emit(end[0], top, literal=True)
169
+ self.i = end.end()
170
+ return
171
+ char = text[self.i]
172
+ line_start = False
173
+ if plain := _PLAIN_HEREDOC.match(text, self.i):
174
+ self.emit(plain[0], top, literal=True)
175
+ self.i = plain.end()
176
+ elif not self.template_part(top):
177
+ self.emit(char, top, literal=True)
178
+ self.i += 1
179
+ line_start = char == "\n"
180
+
181
+
182
+ @functools.lru_cache(maxsize=256)
183
+ def _scan(text: str) -> tuple[str, str, str]:
184
+ """Return (code without comments, top-level parts, expressions) for HCL text."""
185
+ scanner = _Scanner(text)
186
+ scanner.expression(top=True, in_template=False)
187
+ return "".join(scanner.code), "".join(scanner.top), "".join(scanner.expr)
188
+
189
+
190
+ def strip_comments(text: str) -> str:
191
+ """Replace HCL comments (``#``, ``//``, ``/* */``) with spaces, keeping line breaks.
192
+
193
+ Comment markers inside quoted strings (e.g., ``"https://..."``), template
194
+ sequences (``"${format("https://%s", var.host)}"``), and heredocs (e.g., a
195
+ ``#!/bin/bash`` line in ``user_data``) are not comments and are kept.
196
+ """
197
+ return _scan(text)[0]
198
+
199
+
200
+ def top_level(text: str) -> str:
201
+ """Return only the unnested, uncommented parts of HCL; everything else becomes spaces.
202
+
203
+ Block headers such as ``variable "a" {`` are kept, while text in block bodies,
204
+ strings used as values, and heredocs is blanked, so it can't look like a
205
+ declaration.
206
+ """
207
+ return _scan(text)[1]
208
+
209
+
210
+ def expressions(text: str) -> str:
211
+ """Return HCL with comments and the literal text of strings and heredocs blanked.
212
+
213
+ Expressions, including those inside ``${...}`` and ``%{...}`` template
214
+ sequences, are kept, so ``"Replaces var.old"`` is not a reference but
215
+ ``"${var.new}"`` is.
216
+ """
217
+ return _scan(text)[2]
218
+
219
+
220
+ # Directories -r never descends into: VCS data and downloaded Terraform modules.
221
+ _SKIP_DIRS = frozenset({".git", ".terraform", ".terragrunt-cache"})
222
+
223
+
224
+ def uses(text: str) -> str:
225
+ """Return :func:`expressions`, minus each variable's references to itself.
226
+
227
+ A ``var.x`` inside ``variable "x" { validation { ... } }`` checks the value but
228
+ doesn't use it, so it shouldn't hide an unused ``x``. References to other
229
+ variables in a validation block still count.
230
+ """
231
+ _, top, expr = _scan(text)
232
+ for header in re.finditer(r'\bvariable\s+"([^"]+)"\s*\{', top):
233
+ close = top.find("}", header.end())
234
+ end = len(top) if close < 0 else close
235
+ own = re.compile(rf"\bvar\.{re.escape(header[1])}(?![\w-])")
236
+ body = own.sub(lambda m: " " * len(m[0]), expr[header.end() : end])
237
+ expr = expr[: header.end()] + body + expr[end:]
238
+ return expr
239
+
240
+
241
+ def remove_files() -> None:
242
+ """Remove files from the os that look like scratchrelaxtv files."""
243
+ logger.info("attempting removal of files")
244
+ pattern = r"^(variables\.\d+|modstub(\.\d+|))\.tf$"
245
+ for root, dirs, files in os.walk(os.getcwd()):
246
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
247
+ for file in filter(lambda x: re.match(pattern, x), files):
248
+ os.remove(os.path.join(root, file))
249
+ logger.info("removed file: %s", os.path.join(root, file))
250
+
251
+
252
+ class BassExtractor:
253
+ """Extract variables from Terraform HCL files to ease module creation."""
254
+
255
+ # valid var names:
256
+ # "A name must start with a letter and may contain only letters,
257
+ # digits, underscores, and dashes."
258
+ var_regex = r"\bvar\.([a-zA-Z][a-zA-Z0-9_-]*)\b"
259
+ variable_regex = r'\bvariable\s+"([^"]+)"'
260
+
261
+ # Inputs are HCL, so comments are ignored. Subclasses reading other
262
+ # formats (e.g., shell templates) turn this off.
263
+ hcl_input = True
264
+
265
+ def __init__(self, args: argparse.Namespace) -> None:
266
+ """Store parsed args and log the attributes of this run."""
267
+ self.args = args
268
+ self.tf_vars: list[str] = []
269
+ self.log_arguments()
270
+
271
+ @property
272
+ def input_files(self) -> list[str]:
273
+ """Return the input file, or every ``*.tf`` file in the current directory."""
274
+ if self.args.input:
275
+ return [self.args.input]
276
+ return sorted(glob.glob("*.tf"))
277
+
278
+ def log_arguments(self) -> None:
279
+ """Log the attributes of this run."""
280
+ logger.info("input files: %s", ", ".join(self.input_files) or "(none)")
281
+ logger.info("output file: %s", self.args.output)
282
+
283
+ if self.args.force:
284
+ logger.info("forcing overwrite of output file")
285
+ else:
286
+ logger.info("not forcing overwrite of output file")
287
+
288
+ if self.args.asc:
289
+ logger.info("ordering output file ascending")
290
+ elif self.args.desc:
291
+ logger.info("ordering output file descending")
292
+ else:
293
+ logger.info("not ordering output file")
294
+
295
+ def _find_non_existing_filename(self) -> None:
296
+ index = 1
297
+ while True:
298
+ if self.args.force or not os.path.isfile(self.args.output):
299
+ return
300
+ filename, file_extension = os.path.splitext(self.args.output)
301
+ pattern = re.compile(r"(.*)\.(\d+)")
302
+ search = pattern.search(filename)
303
+ if search:
304
+ filename = search.group(1)
305
+ index = int(search.group(2)) + 1
306
+ self.args.output = "".join([filename, ".", str(index), file_extension])
307
+ index += 1
308
+
309
+ @staticmethod
310
+ def get_file_contents(filename: str) -> str:
311
+ """Return contents of file as a string."""
312
+ with open(filename, encoding="utf-8") as file_handle:
313
+ return file_handle.read()
314
+
315
+ @staticmethod
316
+ def find_vars(haystack: str, regex: str) -> list[str]:
317
+ """Return unique regex matches in order of first appearance."""
318
+ var_pattern = re.compile(regex)
319
+ tf_vars: list[str] = var_pattern.findall(haystack)
320
+ return list(dict.fromkeys(tf_vars))
321
+
322
+ def read(self, filenames: list[str], top: bool = False) -> str:
323
+ """Return the contents of files, reduced to the parts that matter if HCL.
324
+
325
+ With ``top``, return only the top level of each HCL file (see :func:`top_level`);
326
+ otherwise, only its expressions (see :func:`expressions`).
327
+ """
328
+ texts = [self.get_file_contents(name) for name in filenames]
329
+ if self.hcl_input:
330
+ texts = [top_level(t) if top else expressions(t) for t in texts]
331
+ return "\n".join(texts)
332
+
333
+ def find_vars_in_file(self, regex: str, top: bool = False) -> None:
334
+ """Extract vars from the input files."""
335
+ self.tf_vars = self.find_vars(self.read(self.input_files, top), regex)
336
+
337
+ if self.args.asc:
338
+ self.tf_vars.sort()
339
+ elif self.args.desc:
340
+ self.tf_vars.sort(reverse=True)
341
+
342
+
343
+ class VarExtractor(BassExtractor):
344
+ """Extract variables from Terraform HCL files to ease module creation."""
345
+
346
+ def __init__(self, args: argparse.Namespace) -> None:
347
+ """Set output default, then initialize the extractor."""
348
+ logger.info("generating variables file")
349
+
350
+ if not args.output:
351
+ args.output = "variables.tf"
352
+
353
+ super().__init__(args)
354
+
355
+ def write_file(self) -> None:
356
+ """Output vars to .tf file."""
357
+ self._find_non_existing_filename()
358
+ with open(self.args.output, "w", encoding="utf-8") as file_handle:
359
+ file_handle.write(
360
+ "\n".join(variable_block(remove_prefix(v, "var.")) for v in self.tf_vars)
361
+ )
362
+
363
+ def extract(self) -> int:
364
+ """Extract vars from .tf files."""
365
+ self.find_vars_in_file(self.var_regex)
366
+ self.write_file()
367
+
368
+ return EXIT_OKAY
369
+
370
+
371
+ class StubMaker(BassExtractor):
372
+ """Extract variables from Terraform HCL files and generate a module stub."""
373
+
374
+ def __init__(self, args: argparse.Namespace) -> None:
375
+ """Set stub output/module-name defaults, then initialize."""
376
+ logger.info("generating module usage stub")
377
+
378
+ if not args.output:
379
+ args.output = "modstub.tf"
380
+
381
+ if not args.modname:
382
+ args.modname = os.path.basename(os.getcwd())
383
+
384
+ super().__init__(args)
385
+
386
+ def write_file(self) -> None:
387
+ """Output vars to .tf file."""
388
+ self._find_non_existing_filename()
389
+ # No providers block: a child module inherits the parent's default
390
+ # provider configurations, and hardcoding one assumed AWS.
391
+ modname = self.args.modname
392
+ args = aligned([(v, f"local.{v}") for v in self.tf_vars], indent=" ")
393
+ with open(self.args.output, "w", encoding="utf-8") as file_handle:
394
+ file_handle.write(f'module "{modname}" {{\n source = "../{modname}"\n')
395
+ if args:
396
+ file_handle.write(f"\n{args}")
397
+ file_handle.write("}\n")
398
+
399
+ def extract(self) -> int:
400
+ """Extract declared variables from .tf files."""
401
+ self.find_vars_in_file(self.variable_regex, top=True)
402
+ self.write_file()
403
+
404
+ return EXIT_OKAY
405
+
406
+
407
+ class EnvGenerator(BassExtractor):
408
+ """Extract variables from Terraform HCL files and generate a .env file."""
409
+
410
+ def __init__(self, args: argparse.Namespace) -> None:
411
+ """Set .env/.tfvars output defaults, then initialize."""
412
+ if args.env:
413
+ logger.info("generating .env file")
414
+ elif args.tfvars:
415
+ logger.info("generating .tfvars file")
416
+
417
+ if not args.output and args.env:
418
+ args.output = ".env"
419
+
420
+ if not args.output and args.tfvars:
421
+ args.output = "terraform.tfvars"
422
+
423
+ super().__init__(args)
424
+
425
+ def _write_env(self) -> None:
426
+ """Output vars to .env file."""
427
+ with open(self.args.output, "w", encoding="utf-8") as file_handle:
428
+ file_handle.write('unset "${!TF_VAR_@}"\n')
429
+ for tf_var in self.tf_vars:
430
+ file_handle.write("".join(["export TF_VAR_", tf_var, "=replace\n"]))
431
+
432
+ def _write_tfvars(self) -> None:
433
+ """Output vars to .tfvars file."""
434
+ with open(self.args.output, "w", encoding="utf-8") as file_handle:
435
+ file_handle.write(aligned([(v, '"replace"') for v in self.tf_vars]))
436
+
437
+ def write_file(self) -> None:
438
+ """Output vars to file."""
439
+ self._find_non_existing_filename()
440
+ if self.args.env:
441
+ self._write_env()
442
+ elif self.args.tfvars:
443
+ self._write_tfvars()
444
+
445
+ def extract(self) -> int:
446
+ """Extract vars from .tf files."""
447
+ self.find_vars_in_file(self.var_regex)
448
+ self.write_file()
449
+
450
+ return EXIT_OKAY
451
+
452
+
453
+ class Checker(BassExtractor):
454
+ """Check that a module declares the variables it uses, and uses the ones it declares."""
455
+
456
+ def __init__(self, args: argparse.Namespace, label: str = ".") -> None:
457
+ """Set check output default, then initialize.
458
+
459
+ ``label`` names the module directory in warnings.
460
+ """
461
+ logger.info("checking for undeclared and unused variables")
462
+ self.label = label
463
+
464
+ if not args.output:
465
+ args.output = "variables.tf"
466
+
467
+ super().__init__(args)
468
+
469
+ def write_file(self) -> None:
470
+ """Append declarations for ``self.tf_vars`` to the output file."""
471
+ existing = (
472
+ self.get_file_contents(self.args.output) if os.path.isfile(self.args.output) else ""
473
+ )
474
+ if not existing.strip():
475
+ separator = ""
476
+ elif existing.endswith("\n"):
477
+ separator = "\n"
478
+ else:
479
+ separator = "\n\n"
480
+ blocks = "\n".join(variable_block(v) for v in self.tf_vars)
481
+ with open(self.args.output, "a", encoding="utf-8") as file_handle:
482
+ file_handle.write(separator + blocks)
483
+
484
+ def find_missing(self) -> dict[str, list[str]]:
485
+ """Return undeclared (used, not declared) and unused (declared, not used) vars."""
486
+ text = "\n".join(uses(self.get_file_contents(name)) for name in self.input_files)
487
+ used = self.find_vars(text, self.var_regex)
488
+
489
+ declaration_files = list(self.input_files)
490
+ if os.path.isfile(self.args.output) and self.args.output not in declaration_files:
491
+ declaration_files.append(self.args.output)
492
+ declared = self.find_vars(self.read(declaration_files, top=True), self.variable_regex)
493
+
494
+ return {
495
+ "undeclared": [v for v in used if v not in declared],
496
+ "unused": [v for v in declared if v not in used],
497
+ }
498
+
499
+ def extract(self) -> int:
500
+ """Report problems, add undeclared variables with ``-f``, and return the exit code."""
501
+ missing = self.find_missing()
502
+
503
+ if missing["undeclared"]:
504
+ logger.warning(
505
+ "%s: undeclared variables: %s", self.label, ", ".join(missing["undeclared"])
506
+ )
507
+ if self.args.force:
508
+ self.tf_vars = missing["undeclared"]
509
+ self.write_file()
510
+ logger.warning("%s: added declarations to %s", self.label, self.args.output)
511
+ if missing["unused"]:
512
+ logger.warning("%s: unused variables: %s", self.label, ", ".join(missing["unused"]))
513
+
514
+ if missing["undeclared"] or missing["unused"]:
515
+ return EXIT_NOT_OKAY
516
+ return EXIT_OKAY
517
+
518
+
519
+ class TemplateExtractor(BassExtractor):
520
+ """Extract template variables from Terraform templates and emit HCL."""
521
+
522
+ var_regex = r"(?<!\$)\${([a-zA-Z][a-zA-Z0-9_-]+)}"
523
+ hcl_input = False
524
+
525
+ def __init__(self, args: argparse.Namespace) -> None:
526
+ """Set template input/output defaults, then initialize."""
527
+ logger.info("extracting template variables")
528
+
529
+ if not args.input:
530
+ args.input = "template.sh"
531
+
532
+ if not args.output:
533
+ args.output = "template_vars.tf"
534
+
535
+ super().__init__(args)
536
+
537
+ def write_file(self) -> None:
538
+ """Output vars to .tf file."""
539
+ self._find_non_existing_filename()
540
+
541
+ with open(self.args.output, "w", encoding="utf-8") as file_handle:
542
+ file_handle.write("locals {\n")
543
+ file_handle.write(" templates_vars = {\n")
544
+ file_handle.write(aligned([(v, '"replace"') for v in self.tf_vars], indent=" "))
545
+ file_handle.write(" }\n}\n")
546
+
547
+ def extract(self) -> int:
548
+ """Extract vars from the template file."""
549
+ self.find_vars_in_file(self.var_regex)
550
+ self.write_file()
551
+
552
+ return EXIT_OKAY
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m scratchrelaxtv``."""
2
+
3
+ import sys
4
+
5
+ from scratchrelaxtv.cli import main
6
+
7
+ sys.exit(main())