envstencil 0.1.0__tar.gz

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,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle Felipe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: envstencil
3
+ Version: 0.1.0
4
+ Summary: CLI Python que gera um .env.example seguro a partir do seu .env — troca os valores por placeholders e preserva comentários e estrutura.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: dotenv,env,environment,template,security
8
+ Author: Kyle Felipe
9
+ Author-email: kylefelipe@gmail.com
10
+ Requires-Python: >=3.10,<4
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Environment :: Console
19
+ Classifier: Natural Language :: Portuguese (Brazilian)
20
+ Requires-Dist: click (>=8.0)
21
+ Project-URL: Documentation, https://env-stencil.readthedocs.io/pt/latest/
22
+ Project-URL: Homepage, https://github.com/kylefelipe/env-stencil
23
+ Project-URL: Issues, https://github.com/kylefelipe/env-stencil/issues
24
+ Project-URL: Repository, https://github.com/kylefelipe/env-stencil
25
+ Description-Content-Type: text/markdown
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/kylefelipe/env-stencil/main/images/logos/envstencil-wordmark.png" alt="EnvStencil" width="320">
29
+ </p>
30
+
31
+ <p align="center">
32
+ Gere um <code>.env.example</code> seguro a partir do seu <code>.env</code>, automaticamente.
33
+ </p>
34
+
35
+ <p align="center">
36
+ 📖 <a href="https://env-stencil.readthedocs.io/pt/latest/">Documentação completa</a>
37
+ </p>
38
+
39
+ # EnvStencil
40
+
41
+ [![Documentation Status](https://app.readthedocs.org/projects/env-stencil/badge/?version=latest)](https://env-stencil.readthedocs.io/en/latest/?badge=latest)
42
+ [![CI](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml/badge.svg)](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml)
43
+ [![codecov](https://codecov.io/github/kylefelipe/env-stencil/graph/badge.svg?token=WTXDCQU4O2)](https://codecov.io/github/kylefelipe/env-stencil)
44
+
45
+ Sempre quando estamos desenvolvendo, é comum a gente criar um arquivo `.env` com as variáveis de ambiente necessárias e depois ter de gerar um arquivo `.env.example` para que outros desenvolvedores possam criar o seu próprio `.env` a partir dele.
46
+
47
+ O problema é que essa é uma tarefa muito chata e repetitiva, então o `envstencil` foi criado para automatizar esse processo.
48
+
49
+ Caso o arquivo `.env` contenha variáveis que não precisam ser sobrescritas no `.env.example`, você pode adicionar o comentário `# envstencil:keep` na linha da variável que deseja manter ou na linha anterior.
50
+
51
+ ## Instalação
52
+
53
+ **pip** — do PyPI ou direto do git:
54
+
55
+ ```bash
56
+ pip install envstencil
57
+ pip install git+https://github.com/kylefelipe/env-stencil.git
58
+ ```
59
+
60
+ **Poetry** — adiciona `envstencil` como dependência do seu projeto:
61
+
62
+ ```bash
63
+ poetry add envstencil
64
+ poetry add git+https://github.com/kylefelipe/env-stencil.git
65
+ ```
66
+
67
+ Com o repositório já clonado: `pip install .` (ou `poetry install`).
68
+
69
+ ## Uso
70
+
71
+ ```bash
72
+ # Gera .env.example a partir de .env no diretório atual
73
+ envstencil generate
74
+
75
+ # Especificar arquivo de origem e destino
76
+ envstencil generate .env.production -o .env.production.example
77
+
78
+ # Ou, gerando a partir do .env padrão
79
+ envstencil generate -o .env.production.example
80
+
81
+ # Placeholder customizado
82
+ envstencil generate --placeholder "CHANGE_ME"
83
+
84
+ # Sobrescrever arquivo existente
85
+ envstencil generate --force
86
+ ```
87
+
88
+ ## Exemplo
89
+
90
+ Entrada (`.env`):
91
+
92
+ ```bash
93
+ # Banco de dados
94
+ DATABASE_URL=postgres://user:pass@localhost:5432/mydb
95
+ STRIPE_SECRET_KEY=sk_live_abc123
96
+ ```
97
+
98
+ Saída (`.env.example`):
99
+
100
+ ```bash
101
+ # Banco de dados
102
+ DATABASE_URL=your_value_here
103
+ STRIPE_SECRET_KEY=your_value_here
104
+ ```
105
+
106
+ ## Desenvolvimento
107
+
108
+ Requer **Poetry 2.0+**. Os grupos `dev` e `doc` são opcionais — `poetry install`
109
+ sozinho instala só o runtime:
110
+
111
+ ```bash
112
+ poetry install --with dev,doc
113
+ ```
114
+
115
+ Tarefas, convenções e fluxo de PR: **[Contribuindo](https://env-stencil.readthedocs.io/pt/latest/contributing/)**.
116
+
@@ -0,0 +1,89 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/kylefelipe/env-stencil/main/images/logos/envstencil-wordmark.png" alt="EnvStencil" width="320">
3
+ </p>
4
+
5
+ <p align="center">
6
+ Gere um <code>.env.example</code> seguro a partir do seu <code>.env</code>, automaticamente.
7
+ </p>
8
+
9
+ <p align="center">
10
+ 📖 <a href="https://env-stencil.readthedocs.io/pt/latest/">Documentação completa</a>
11
+ </p>
12
+
13
+ # EnvStencil
14
+
15
+ [![Documentation Status](https://app.readthedocs.org/projects/env-stencil/badge/?version=latest)](https://env-stencil.readthedocs.io/en/latest/?badge=latest)
16
+ [![CI](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml/badge.svg)](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml)
17
+ [![codecov](https://codecov.io/github/kylefelipe/env-stencil/graph/badge.svg?token=WTXDCQU4O2)](https://codecov.io/github/kylefelipe/env-stencil)
18
+
19
+ Sempre quando estamos desenvolvendo, é comum a gente criar um arquivo `.env` com as variáveis de ambiente necessárias e depois ter de gerar um arquivo `.env.example` para que outros desenvolvedores possam criar o seu próprio `.env` a partir dele.
20
+
21
+ O problema é que essa é uma tarefa muito chata e repetitiva, então o `envstencil` foi criado para automatizar esse processo.
22
+
23
+ Caso o arquivo `.env` contenha variáveis que não precisam ser sobrescritas no `.env.example`, você pode adicionar o comentário `# envstencil:keep` na linha da variável que deseja manter ou na linha anterior.
24
+
25
+ ## Instalação
26
+
27
+ **pip** — do PyPI ou direto do git:
28
+
29
+ ```bash
30
+ pip install envstencil
31
+ pip install git+https://github.com/kylefelipe/env-stencil.git
32
+ ```
33
+
34
+ **Poetry** — adiciona `envstencil` como dependência do seu projeto:
35
+
36
+ ```bash
37
+ poetry add envstencil
38
+ poetry add git+https://github.com/kylefelipe/env-stencil.git
39
+ ```
40
+
41
+ Com o repositório já clonado: `pip install .` (ou `poetry install`).
42
+
43
+ ## Uso
44
+
45
+ ```bash
46
+ # Gera .env.example a partir de .env no diretório atual
47
+ envstencil generate
48
+
49
+ # Especificar arquivo de origem e destino
50
+ envstencil generate .env.production -o .env.production.example
51
+
52
+ # Ou, gerando a partir do .env padrão
53
+ envstencil generate -o .env.production.example
54
+
55
+ # Placeholder customizado
56
+ envstencil generate --placeholder "CHANGE_ME"
57
+
58
+ # Sobrescrever arquivo existente
59
+ envstencil generate --force
60
+ ```
61
+
62
+ ## Exemplo
63
+
64
+ Entrada (`.env`):
65
+
66
+ ```bash
67
+ # Banco de dados
68
+ DATABASE_URL=postgres://user:pass@localhost:5432/mydb
69
+ STRIPE_SECRET_KEY=sk_live_abc123
70
+ ```
71
+
72
+ Saída (`.env.example`):
73
+
74
+ ```bash
75
+ # Banco de dados
76
+ DATABASE_URL=your_value_here
77
+ STRIPE_SECRET_KEY=your_value_here
78
+ ```
79
+
80
+ ## Desenvolvimento
81
+
82
+ Requer **Poetry 2.0+**. Os grupos `dev` e `doc` são opcionais — `poetry install`
83
+ sozinho instala só o runtime:
84
+
85
+ ```bash
86
+ poetry install --with dev,doc
87
+ ```
88
+
89
+ Tarefas, convenções e fluxo de PR: **[Contribuindo](https://env-stencil.readthedocs.io/pt/latest/contributing/)**.
@@ -0,0 +1,82 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=2.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "envstencil"
7
+ version = "0.1.0"
8
+ description = "CLI Python que gera um .env.example seguro a partir do seu .env — troca os valores por placeholders e preserva comentários e estrutura."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10,<4"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Kyle Felipe", email = "kylefelipe@gmail.com" }
14
+ ]
15
+ keywords = ["dotenv", "env", "environment", "template", "security"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ "Environment :: Console",
25
+ "Natural Language :: Portuguese (Brazilian)",
26
+ ]
27
+ dependencies = [
28
+ "click>=8.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/kylefelipe/env-stencil"
33
+ Documentation = "https://env-stencil.readthedocs.io/pt/latest/"
34
+ Repository = "https://github.com/kylefelipe/env-stencil"
35
+ Issues = "https://github.com/kylefelipe/env-stencil/issues"
36
+
37
+ [project.scripts]
38
+ envstencil = "envstencil.cli:main"
39
+
40
+ [tool.poetry]
41
+ packages = [{ include = "envstencil", from = "src" }]
42
+
43
+ [tool.poetry.group.dev]
44
+ optional = true
45
+
46
+ [tool.poetry.group.dev.dependencies]
47
+ pytest = "^7.0.0"
48
+ pytest-cov = "^7.1.0"
49
+ isort = "^9.0.1"
50
+ taskipy = "^1.14.1"
51
+ black = "^26.5.1"
52
+
53
+ [tool.poetry.group.doc]
54
+ optional = true
55
+
56
+ [tool.poetry.group.doc.dependencies]
57
+ mkdocs-material = "^9.7.7"
58
+ mkdocstrings = "^1.0.6"
59
+ mkdocstrings-python = "^2.0.7"
60
+ mkdocs-click = "^0.9.0"
61
+ mkdocs-macros-plugin = "^1.5.0"
62
+ jinja2 = "^3.1.6"
63
+
64
+ [tool.isort]
65
+ profile = "black"
66
+ line_length = 79
67
+
68
+ [tool.black]
69
+ line-length = 79
70
+ target-version = ["py310"]
71
+
72
+ [tool.pytest.ini_options]
73
+ pythonpath = "."
74
+ addopts = "--doctest-modules"
75
+
76
+ [tool.taskipy.tasks]
77
+ pre_test = "task lint"
78
+ test = "pytest -s -x --cov=envstencil -vv"
79
+ post_test = "coverage html"
80
+ lint = "black --check --diff . && isort --check --diff ."
81
+ fmt = "isort . && black ."
82
+ docs = "mkdocs serve"
@@ -0,0 +1,17 @@
1
+ """envstencil — gera um .env.example seguro a partir do seu .env."""
2
+
3
+ from .core import (
4
+ DEFAULT_PLACEHOLDER,
5
+ generate_example,
6
+ parse_env_file,
7
+ render_stencil,
8
+ )
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "DEFAULT_PLACEHOLDER",
14
+ "generate_example",
15
+ "parse_env_file",
16
+ "render_stencil",
17
+ ]
@@ -0,0 +1,82 @@
1
+ """Command-line interface for envstencil."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from .core import DEFAULT_PLACEHOLDER, generate_example
10
+
11
+
12
+ @click.group()
13
+ @click.version_option()
14
+ def main() -> None:
15
+ """envstencil — gera um .env.example seguro a partir do seu .env."""
16
+
17
+
18
+ @main.command()
19
+ @click.argument(
20
+ "source",
21
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
22
+ default=".env",
23
+ required=False,
24
+ )
25
+ @click.option(
26
+ "-o",
27
+ "--output",
28
+ "destination",
29
+ type=click.Path(dir_okay=False, path_type=Path),
30
+ default=None,
31
+ help="Arquivo de saída (padrão: origem + '.example', no mesmo diretório).",
32
+ )
33
+ @click.option(
34
+ "-p",
35
+ "--placeholder",
36
+ default=DEFAULT_PLACEHOLDER,
37
+ show_default=True,
38
+ help="Texto usado para substituir cada valor.",
39
+ )
40
+ @click.option(
41
+ "-f",
42
+ "--force",
43
+ is_flag=True,
44
+ default=False,
45
+ help="Sobrescreve o arquivo de destino se ele já existir.",
46
+ )
47
+ @click.option(
48
+ "-b",
49
+ "--collapse-blank-lines",
50
+ is_flag=True,
51
+ default=False,
52
+ help="Colapsa linhas em branco consecutivas em uma só.",
53
+ )
54
+ def generate(
55
+ source: Path,
56
+ destination: Path | None,
57
+ placeholder: str,
58
+ force: bool,
59
+ collapse_blank_lines: bool,
60
+ ) -> None:
61
+ """Gera um .env.example a partir de SOURCE (padrão: .env)."""
62
+ if destination is None:
63
+ destination = source.parent / f"{source.name}.example"
64
+
65
+ try:
66
+ result = generate_example(
67
+ source=source,
68
+ destination=destination,
69
+ placeholder=placeholder,
70
+ force=force,
71
+ collapse_blank_lines=collapse_blank_lines,
72
+ )
73
+ except FileExistsError as exc:
74
+ raise click.ClickException(str(exc)) from exc
75
+ except FileNotFoundError as exc:
76
+ raise click.ClickException(str(exc)) from exc
77
+
78
+ click.echo(f"✅ {result} gerado a partir de {source}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,245 @@
1
+ """Core logic for envstencil: parse .env files and generate a safe .env.example."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_PLACEHOLDER = "your_value_here"
10
+
11
+ # Matches KEY=VALUE lines, tolerating optional `export ` prefix and spaces
12
+ # around the `=`. Keys follow standard shell/dotenv identifier rules.
13
+ _KEY_VALUE_RE = re.compile(
14
+ r"^(?P<prefix>export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<value>.*)$"
15
+ )
16
+
17
+ # Opt-in directive that tells envstencil to keep the real value in the stencil
18
+ # instead of replacing it with the placeholder. Accepted either as an inline
19
+ # comment on the pair itself or as a standalone comment on the line above it.
20
+ _KEEP_MARKER_RE = re.compile(r"#\s*envstencil\s*:\s*keep\b", re.IGNORECASE)
21
+
22
+
23
+ def _split_inline_comment(value: str) -> tuple[str, str]:
24
+ """Split a parsed value into ``(value, inline_comment)``.
25
+
26
+ An inline comment starts at the first ``#`` that sits outside quotes and is
27
+ either at the start or preceded by whitespace (dotenv convention). The
28
+ returned ``inline_comment`` keeps its leading whitespace and the ``#`` so it
29
+ can be re-appended verbatim; it is ``""`` when there is no inline comment.
30
+ """
31
+ quote = ""
32
+ i = 0
33
+ while i < len(value):
34
+ ch = value[i]
35
+ if quote:
36
+ if ch == "\\" and quote == '"' and i + 1 < len(value):
37
+ i += 2
38
+ continue
39
+ if ch == quote:
40
+ quote = ""
41
+ elif ch in ("'", '"'):
42
+ quote = ch
43
+ elif ch == "#" and (i == 0 or value[i - 1].isspace()):
44
+ head = value[:i].rstrip()
45
+ return head, value[len(head) :]
46
+ i += 1
47
+ return value, ""
48
+
49
+
50
+ def _strip_keep_marker(comment: str) -> str:
51
+ """Remove the `# envstencil:keep` directive from a comment, keeping any other
52
+ documentation text. Returns the leftover as an inline suffix (a single
53
+ `" # ..."`), or ``""`` when the comment was only the directive.
54
+ """
55
+ cleaned = _KEEP_MARKER_RE.sub("", comment).strip()
56
+ if not cleaned.strip("#").strip():
57
+ return ""
58
+ body = cleaned[1:].lstrip() if cleaned.startswith("#") else cleaned
59
+ return f" # {body}"
60
+
61
+
62
+ @dataclass
63
+ class EnvLine:
64
+ """Represents one parsed line from a .env file.
65
+
66
+ Attributes:
67
+ raw: The original line text, verbatim.
68
+ kind: One of `"comment"`, `"blank"`, `"pair"`, or `"unknown"`.
69
+ key: Variable name, when `kind == "pair"`.
70
+ value: Variable value without any inline comment, when `kind == "pair"`.
71
+ prefix: Leading `"export "` when present, otherwise `""`.
72
+ inline_comment: Trailing `# ...` of a pair, with its leading space.
73
+ keep: Whether the pair is flagged with `# envstencil:keep`.
74
+ """
75
+
76
+ raw: str
77
+ kind: str # "comment", "blank", "pair", "unknown"
78
+ key: str | None = None
79
+ value: str | None = None
80
+ prefix: str = ""
81
+ inline_comment: str = (
82
+ "" # trailing `# ...` on a pair (with its leading space)
83
+ )
84
+ keep: bool = False # pair marked with `# envstencil:keep`
85
+
86
+
87
+ def parse_env_file(path: Path) -> list[EnvLine]:
88
+ """Parse a .env file into ordered EnvLine entries.
89
+
90
+ Blank lines and comments are preserved so the structure can be mirrored
91
+ in the output. The `# envstencil:keep` directive is never kept as an
92
+ EnvLine: a standalone directive line is dropped (only its effect
93
+ survives, on the next pair) and an inline directive is stripped from the
94
+ pair's comment.
95
+
96
+ Args:
97
+ path: Path to the `.env` file to read.
98
+
99
+ Returns:
100
+ The parsed lines, in file order.
101
+ """
102
+
103
+ lines: list[EnvLine] = []
104
+ text = path.read_text(encoding="utf-8")
105
+
106
+ # Set by a standalone `# envstencil:keep` comment and consumed by the next
107
+ # pair. Blank lines in between are tolerated; any other line clears it.
108
+ pending_keep = False
109
+
110
+ for raw_line in text.splitlines():
111
+ stripped = raw_line.strip()
112
+
113
+ if not stripped:
114
+ lines.append(EnvLine(raw=raw_line, kind="blank"))
115
+ continue
116
+
117
+ if stripped.startswith("#"):
118
+ if _KEEP_MARKER_RE.search(stripped):
119
+ pending_keep = True
120
+ remainder = _strip_keep_marker(stripped).lstrip()
121
+ if remainder:
122
+ lines.append(EnvLine(raw=remainder, kind="comment"))
123
+ else:
124
+ lines.append(EnvLine(raw=raw_line, kind="comment"))
125
+ continue
126
+
127
+ match = _KEY_VALUE_RE.match(stripped)
128
+ if match:
129
+ value, inline_comment = _split_inline_comment(match.group("value"))
130
+ has_inline_marker = bool(_KEEP_MARKER_RE.search(inline_comment))
131
+ if has_inline_marker:
132
+ inline_comment = _strip_keep_marker(inline_comment)
133
+ lines.append(
134
+ EnvLine(
135
+ raw=raw_line,
136
+ kind="pair",
137
+ key=match.group("key"),
138
+ value=value,
139
+ prefix=match.group("prefix") or "",
140
+ inline_comment=inline_comment,
141
+ keep=pending_keep or has_inline_marker,
142
+ )
143
+ )
144
+ pending_keep = False
145
+ continue
146
+
147
+ lines.append(EnvLine(raw=raw_line, kind="unknown"))
148
+ pending_keep = False
149
+
150
+ return lines
151
+
152
+
153
+ def _collapse_blank_lines(rows: list[str]) -> list[str]:
154
+ """Collapse every run of consecutive blank lines down to a single one."""
155
+ collapsed: list[str] = []
156
+ prev_blank = False
157
+ for row in rows:
158
+ is_blank = not row.strip()
159
+ if is_blank and prev_blank:
160
+ continue
161
+ collapsed.append(row)
162
+ prev_blank = is_blank
163
+ return collapsed
164
+
165
+
166
+ def render_stencil(
167
+ lines: list[EnvLine],
168
+ placeholder: str = DEFAULT_PLACEHOLDER,
169
+ collapse_blank_lines: bool = False,
170
+ ) -> str:
171
+ """Render parsed EnvLine entries into a .env.example body.
172
+
173
+ Every value is replaced with `placeholder` while comments and structure
174
+ are kept. Inline comments documenting a pair are preserved. Pairs flagged
175
+ with `# envstencil:keep` keep their real value, and the directive itself
176
+ never appears in the output.
177
+
178
+ Args:
179
+ lines: Parsed entries from
180
+ [`parse_env_file`][envstencil.core.parse_env_file].
181
+ placeholder: Text that replaces each value.
182
+ collapse_blank_lines: If `True`, reduce every run of consecutive
183
+ blank lines to a single one.
184
+
185
+ Returns:
186
+ The rendered `.env.example` content, ending with a newline.
187
+ """
188
+
189
+ output_lines: list[str] = []
190
+
191
+ for line in lines:
192
+ if line.kind in ("comment", "blank", "unknown"):
193
+ output_lines.append(line.raw)
194
+ elif line.kind == "pair":
195
+ rendered_value = line.value if line.keep else placeholder
196
+ output_lines.append(
197
+ f"{line.prefix}{line.key}={rendered_value}{line.inline_comment}"
198
+ )
199
+
200
+ if collapse_blank_lines:
201
+ output_lines = _collapse_blank_lines(output_lines)
202
+
203
+ return "\n".join(output_lines) + "\n"
204
+
205
+
206
+ def generate_example(
207
+ source: Path,
208
+ destination: Path,
209
+ placeholder: str = DEFAULT_PLACEHOLDER,
210
+ force: bool = False,
211
+ collapse_blank_lines: bool = False,
212
+ ) -> Path:
213
+ """Read `source` and write a stencil to `destination`.
214
+
215
+ Args:
216
+ source: Path to the source `.env` file.
217
+ destination: Path of the `.env.example` to write.
218
+ placeholder: Text that replaces each value.
219
+ force: If `True`, overwrite `destination` when it already exists.
220
+ collapse_blank_lines: If `True`, collapse consecutive blank lines.
221
+
222
+ Returns:
223
+ The `destination` path.
224
+
225
+ Raises:
226
+ FileNotFoundError: If `source` does not exist.
227
+ FileExistsError: If `destination` exists and `force` is `False`.
228
+ """
229
+
230
+ if not source.exists():
231
+ raise FileNotFoundError(f"Arquivo de origem não encontrado: {source}")
232
+
233
+ if destination.exists() and not force:
234
+ raise FileExistsError(
235
+ f"{destination} já existe. Use --force para sobrescrever."
236
+ )
237
+
238
+ lines = parse_env_file(source)
239
+ content = render_stencil(
240
+ lines,
241
+ placeholder=placeholder,
242
+ collapse_blank_lines=collapse_blank_lines,
243
+ )
244
+ destination.write_text(content, encoding="utf-8")
245
+ return destination