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.
envstencil-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://env-stencil.readthedocs.io/en/latest/?badge=latest)
|
|
42
|
+
[](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml)
|
|
43
|
+
[](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
|
+
[](https://env-stencil.readthedocs.io/en/latest/?badge=latest)
|
|
16
|
+
[](https://github.com/kylefelipe/env-stencil/actions/workflows/ci.yml)
|
|
17
|
+
[](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
|