Skip to content

Commit 9e6a61f

Browse files
committed
✨ Add convert-config script for YAML and environment variable conversion
1 parent 72f3ea8 commit 9e6a61f

File tree

7 files changed

+280
-3
lines changed

7 files changed

+280
-3
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export = "pdm export -o requirements.txt --prod"
3636
tests = "pytest tests"
3737
start = "python src"
3838
check-listings = {call = "scripts:check_listings.main"}
39+
convert-config = { call = "scripts.convert_config:main" }
3940

4041
[tool.pdm]
4142
distribution = false
@@ -80,7 +81,7 @@ docstring-code-line-length = "dynamic"
8081

8182
[tool.ruff.lint]
8283
select = ["ALL"]
83-
per-file-ignores = { "src/database/migrations/*"= ["INP001", "ARG001"] }
84+
per-file-ignores = { "src/database/migrations/*"= ["INP001", "ARG001"], "tests/*"= ["S101"], "scripts/**" = ["T201"] }
8485
extend-ignore = [
8586
"N999",
8687
"D104",

readme.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ BOTKIT__cache__redis__host=redis.example.com
119119
BOTKIT__cache__redis__port=6379
120120
```
121121

122+
### Converting Configuration Formats
123+
124+
When deploying in containerized or high-availability environments where persistent
125+
volumes might not be available or when using orchestration platforms like Docker Swarm,
126+
it's often preferable to use environment variables instead of configuration files.
127+
Botkit provides a convenient script to convert between YAML and environment variable
128+
formats:
129+
130+
```bash
131+
pdm run convert-config -i config.yml --terminal
132+
```
133+
134+
This will output your YAML configuration as environment variables that you can then use
135+
in your container configuration or deployment platform. For more details about the
136+
convert-config script, see the [Using scripts](#using-scripts) section.
137+
122138
### Cache Configuration
123139

124140
Botkit supports two types of caching:
@@ -416,6 +432,45 @@ DiscordMe: # add this section if you want to check discord.me
416432

417433
4. Run the script using `pdm run check-listings`.
418434

435+
### `convert-config`
436+
437+
This script converts the configuration between YAML and env formats.
438+
439+
#### Usage
440+
441+
By default, if run with no arguments, it converts the `config.yaml` or `config.yml`
442+
present in the root to `.env` format. If a `.env` file is present and is empty or one is
443+
not present, it converts there. Otherwise, it asks the user if it should overwrite the
444+
existing `.env` file. If not, it writes to a `datetime.converted.env` file.
445+
446+
#### Options
447+
448+
- `-i`, `--input`: Specify the input file path.
449+
- `--input-format`: Specify the input format (`yaml`, `yml`, `env`).
450+
- `--output`: Specify the output file path.
451+
- `--output-format`: Specify the output format (`yaml`, `yml`, `env`).
452+
- `--terminal`: Output to the terminal instead of a file.
453+
454+
#### Examples
455+
456+
Convert `config.yaml` to `.env`:
457+
458+
```sh
459+
pdm run convert-config
460+
```
461+
462+
Convert a specific file and output to the terminal:
463+
464+
```sh
465+
pdm run convert-config -i config.yml --terminal
466+
```
467+
468+
Convert `.env` to `config.yaml`:
469+
470+
```sh
471+
pdm run convert-config -i .env --output config.yaml
472+
```
473+
419474
## Provided Extensions
420475

421476
We provide multiple extensions directly within this project to get you started. These

scripts/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Copyright (c) NiceBots.xyz
22
# SPDX-License-Identifier: MIT
33

4-
from . import check_listings
4+
from . import check_listings, convert_config
55

6-
__all__ = ["check_listings"]
6+
__all__ = ["check_listings", "convert_config"]

scripts/convert_config/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) NiceBots
2+
# SPDX-License-Identifier: MIT
3+
4+
from .__main__ import main
5+
6+
__all__ = ["main"]

scripts/convert_config/__main__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright (c) NiceBots
2+
# SPDX-License-Identifier: MIT
3+
4+
import argparse
5+
import sys
6+
from datetime import UTC, datetime
7+
from pathlib import Path
8+
9+
from .convert import env_to_yaml, yaml_to_env
10+
11+
12+
def main() -> None: # noqa: PLR0912
13+
parser = argparse.ArgumentParser(description="Convert config between YAML and env formats.")
14+
parser.add_argument("-i", "--input", help="Input file path", default=None)
15+
parser.add_argument("--input-format", help="Input format (yaml, yml, env)", default=None)
16+
parser.add_argument("--output", help="Output file path", default=None)
17+
parser.add_argument("--output-format", help="Output format (yaml, yml, env)", default=None)
18+
parser.add_argument("--terminal", action="store_true", help="Output to terminal instead of file")
19+
20+
args = parser.parse_args()
21+
22+
input_path = Path(args.input) if args.input else None
23+
output_path = Path(args.output) if args.output else None
24+
input_format = args.input_format
25+
output_format = args.output_format
26+
terminal = args.terminal
27+
28+
if not input_path:
29+
if Path("config.yaml").exists():
30+
input_path = Path("config.yaml")
31+
elif Path("config.yml").exists():
32+
input_path = Path("config.yml")
33+
elif Path(".env").exists():
34+
input_path = Path(".env")
35+
else:
36+
print("No input file found.")
37+
sys.exit(1)
38+
39+
input_format = input_format or input_path.suffix[1:] if input_path.name != ".env" else "env"
40+
41+
if not output_format:
42+
output_format = "env" if input_format in ["yaml", "yml"] else "yaml"
43+
44+
if terminal:
45+
output_path = None
46+
elif not output_path:
47+
if output_format == "env":
48+
output_path = Path(".env")
49+
if output_path.exists() and output_path.stat().st_size != 0:
50+
response = input(".env file is not empty. Overwrite? (y/n): ")
51+
if response.lower() != "y":
52+
output_path = Path(f"{datetime.now(tz=UTC).strftime('%Y%m%d%H%M%S')}.converted.env")
53+
else:
54+
output_path = Path("config.yaml")
55+
56+
# Fixed conversion logic
57+
if input_format in ["yaml", "yml"] and output_format == "env":
58+
yaml_to_env(input_path, output_path)
59+
elif input_format == "env" and output_format in ["yaml", "yml"]:
60+
env_to_yaml(input_path, output_path)
61+
else:
62+
print(f"Invalid conversion from '{input_format}' to '{output_format}'")
63+
print("Supported conversions: yaml->env, yml->env, env->yaml, env->yml")
64+
sys.exit(1)
65+
66+
67+
if __name__ == "__main__":
68+
main()

scripts/convert_config/convert.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright (c) NiceBots
2+
# SPDX-License-Identifier: MIT
3+
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import yaml
8+
from dotenv import dotenv_values
9+
10+
11+
def yaml_to_env(yaml_path: Path, output_path: Path | None = None) -> None:
12+
with yaml_path.open("r", encoding="utf-8") as yaml_file:
13+
config = yaml.safe_load(yaml_file)
14+
15+
env_lines = []
16+
17+
def recurse_dict(d: dict[Any, Any], prefix: str = "") -> None:
18+
for key, val in d.items():
19+
if isinstance(val, dict):
20+
recurse_dict(val, f"{prefix}{key.lower()}__")
21+
else:
22+
# Convert boolean values to lowercase strings
23+
env_value = str(val).lower() if isinstance(val, bool) else val
24+
env_lines.append(f"BOTKIT__{prefix}{key.lower()}={env_value}")
25+
26+
recurse_dict(config)
27+
28+
if output_path:
29+
with output_path.open("w", encoding="utf-8") as env_file:
30+
env_file.write("\n".join(env_lines) + "\n") # Add final newline
31+
else:
32+
print("\n".join(env_lines))
33+
34+
35+
def env_to_yaml(env_path: Path, output_path: Path | None = None) -> None:
36+
env_config = dotenv_values(env_path)
37+
yaml_config = {}
38+
39+
for key, val in env_config.items():
40+
if not key.startswith("BOTKIT__"):
41+
continue
42+
parts = key[8:].split("__") # Remove BOTKIT__ prefix
43+
current = yaml_config
44+
for part in parts[:-1]:
45+
current = current.setdefault(part.lower(), {})
46+
47+
# Convert string boolean values to actual booleans
48+
yaml_value = val
49+
if val.lower() == "true":
50+
yaml_value = True
51+
elif val.lower() == "false":
52+
yaml_value = False
53+
54+
current[parts[-1].lower()] = yaml_value
55+
56+
if output_path:
57+
with output_path.open("w", encoding="utf-8") as yaml_file:
58+
yaml.safe_dump(yaml_config, yaml_file) # Use safe_dump to avoid string quotes
59+
else:
60+
print(yaml.safe_dump(yaml_config))

tests/test_convert_config.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright (c) NiceBots
2+
# SPDX-License-Identifier: MIT
3+
4+
import os
5+
import tempfile
6+
from pathlib import Path
7+
8+
from scripts.convert_config.convert import env_to_yaml, yaml_to_env
9+
10+
11+
def test_yaml_to_env() -> None:
12+
yaml_content = """
13+
bot:
14+
token: "your_bot_token"
15+
extensions:
16+
listings:
17+
enabled: false
18+
topgg_token: "your_top.gg_token"
19+
ping:
20+
enabled: true
21+
logging:
22+
level: INFO
23+
"""
24+
with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as yaml_file:
25+
yaml_file.write(yaml_content.encode("utf-8"))
26+
yaml_path = Path(yaml_file.name)
27+
28+
with tempfile.NamedTemporaryFile(delete=False, suffix=".env") as env_file:
29+
env_path = Path(env_file.name)
30+
31+
yaml_to_env(yaml_path, env_path)
32+
33+
with env_path.open("r", encoding="utf-8") as env_file:
34+
env_content = env_file.read()
35+
36+
expected_env_content = (
37+
"BOTKIT__bot__token=your_bot_token\n"
38+
"BOTKIT__extensions__listings__enabled=false\n"
39+
"BOTKIT__extensions__listings__topgg_token=your_top.gg_token\n"
40+
"BOTKIT__extensions__ping__enabled=true\n"
41+
"BOTKIT__logging__level=INFO\n"
42+
)
43+
44+
assert env_content == expected_env_content
45+
46+
os.remove(yaml_path)
47+
os.remove(env_path)
48+
49+
50+
def test_env_to_yaml() -> None:
51+
env_content = (
52+
"BOTKIT__bot__token=your_bot_token\n"
53+
"BOTKIT__extensions__listings__enabled=false\n"
54+
"BOTKIT__extensions__listings__topgg_token=your_top.gg_token\n"
55+
"BOTKIT__extensions__ping__enabled=true\n"
56+
"BOTKIT__logging__level=INFO\n"
57+
"SOME_OTHER_VAR=should_be_ignored\n" # This should be ignored
58+
)
59+
with tempfile.NamedTemporaryFile(delete=False, suffix=".env") as env_file:
60+
env_file.write(env_content.encode("utf-8"))
61+
env_path = Path(env_file.name)
62+
63+
with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml") as yaml_file:
64+
yaml_path = Path(yaml_file.name)
65+
66+
env_to_yaml(env_path, yaml_path)
67+
68+
with yaml_path.open("r", encoding="utf-8") as yaml_file:
69+
yaml_content = yaml_file.read()
70+
71+
expected_yaml_content = (
72+
"bot:\n"
73+
" token: your_bot_token\n"
74+
"extensions:\n"
75+
" listings:\n"
76+
" enabled: false\n"
77+
" topgg_token: your_top.gg_token\n"
78+
" ping:\n"
79+
" enabled: true\n"
80+
"logging:\n"
81+
" level: INFO\n"
82+
)
83+
84+
assert yaml_content == expected_yaml_content
85+
86+
os.remove(env_path)
87+
os.remove(yaml_path)

0 commit comments

Comments
 (0)