cfglock 0.1.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.
- cfglock/__init__.py +8 -0
- cfglock/cli.py +68 -0
- cfglock/docs_server.py +115 -0
- cfglock/extraction.py +24 -0
- cfglock/helper.py +127 -0
- cfglock/test_config.json +9 -0
- cfglock/validator.py +159 -0
- cfglock-0.1.0.dist-info/METADATA +113 -0
- cfglock-0.1.0.dist-info/RECORD +11 -0
- cfglock-0.1.0.dist-info/WHEEL +4 -0
- cfglock-0.1.0.dist-info/entry_points.txt +6 -0
cfglock/__init__.py
ADDED
cfglock/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from cfglock.validator import ConfigLockError
|
|
4
|
+
|
|
5
|
+
from .helper import (
|
|
6
|
+
write_json,
|
|
7
|
+
check_file_exists,
|
|
8
|
+
check_file_identicality,
|
|
9
|
+
check_comp_cli,
|
|
10
|
+
check_file_and_read_file,
|
|
11
|
+
)
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="ConfigLock: Secure GitOps YAML validation engine.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def init(
|
|
19
|
+
file_path: Annotated[
|
|
20
|
+
str, typer.Argument(help="the path for the newly proposed file")
|
|
21
|
+
],
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Reads a YAML config and generates a lockfile.
|
|
25
|
+
"""
|
|
26
|
+
if check_file_exists():
|
|
27
|
+
typer.echo("File already exists!")
|
|
28
|
+
else:
|
|
29
|
+
data = check_file_and_read_file(file_path)
|
|
30
|
+
write_json(data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def sync(
|
|
35
|
+
file_path: Annotated[
|
|
36
|
+
str, typer.Argument(help="the path for the newly proposed file")
|
|
37
|
+
],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Used to check if lock file and proposed file are out of sync
|
|
41
|
+
"""
|
|
42
|
+
if check_file_identicality(file_path):
|
|
43
|
+
typer.echo("The file has not changed.")
|
|
44
|
+
else:
|
|
45
|
+
raise ConfigLockError(
|
|
46
|
+
"The lock file is outdated, run sync to update the lock file!", error_code=1
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def lock(
|
|
52
|
+
file_path: Annotated[
|
|
53
|
+
str, typer.Argument(help="the path for the newly proposed file")
|
|
54
|
+
],
|
|
55
|
+
order_matters: bool = typer.Option(
|
|
56
|
+
False,
|
|
57
|
+
"--order-matters/--no-order-matters",
|
|
58
|
+
help="choose if the order of the keys matter or not",
|
|
59
|
+
),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Used to update the lock file, IF compatible
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
check_comp_cli(file_path, order_matters)
|
|
66
|
+
data = check_file_and_read_file(file_path)
|
|
67
|
+
write_json(data)
|
|
68
|
+
|
cfglock/docs_server.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import http.server
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from watchdog.events import FileSystemEventHandler
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
RELOAD_SCRIPT = b"""
|
|
14
|
+
<script>
|
|
15
|
+
async function waitForReload() {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch('/__reload__', { cache: 'no-store' });
|
|
18
|
+
if (response.status === 204) {
|
|
19
|
+
location.reload();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.log('reload poll failed', error);
|
|
24
|
+
}
|
|
25
|
+
setTimeout(waitForReload, 1000);
|
|
26
|
+
}
|
|
27
|
+
waitForReload();
|
|
28
|
+
</script>
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReloadState:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.condition = threading.Condition()
|
|
35
|
+
self.generation = 0
|
|
36
|
+
|
|
37
|
+
def bump(self) -> None:
|
|
38
|
+
with self.condition:
|
|
39
|
+
self.generation += 1
|
|
40
|
+
self.condition.notify_all()
|
|
41
|
+
|
|
42
|
+
def wait_for_next(self, generation: int) -> None:
|
|
43
|
+
with self.condition:
|
|
44
|
+
self.condition.wait_for(lambda: self.generation > generation)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReloadHandler(FileSystemEventHandler):
|
|
48
|
+
def __init__(self, state: ReloadState) -> None:
|
|
49
|
+
self.state = state
|
|
50
|
+
|
|
51
|
+
def on_any_event(self, event) -> None:
|
|
52
|
+
if not event.is_directory:
|
|
53
|
+
self.state.bump()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DocsHandler(http.server.SimpleHTTPRequestHandler):
|
|
57
|
+
def __init__(self, *args, docs_root: Path, state: ReloadState, **kwargs):
|
|
58
|
+
self.docs_root = docs_root
|
|
59
|
+
self.state = state
|
|
60
|
+
super().__init__(*args, directory=str(docs_root), **kwargs)
|
|
61
|
+
|
|
62
|
+
def end_headers(self) -> None:
|
|
63
|
+
if self.path.endswith(".html") or self.path in {"/", ""}:
|
|
64
|
+
self.send_header("Cache-Control", "no-store")
|
|
65
|
+
super().end_headers()
|
|
66
|
+
|
|
67
|
+
def do_GET(self) -> None:
|
|
68
|
+
if self.path == "/__reload__":
|
|
69
|
+
generation = self.state.generation
|
|
70
|
+
self.state.wait_for_next(generation)
|
|
71
|
+
self.send_response(204)
|
|
72
|
+
self.end_headers()
|
|
73
|
+
return
|
|
74
|
+
super().do_GET()
|
|
75
|
+
|
|
76
|
+
def copyfile(self, source, outputfile) -> None:
|
|
77
|
+
content = source.read()
|
|
78
|
+
if b"</html>" in content:
|
|
79
|
+
content = content.replace(b"</html>", RELOAD_SCRIPT + b"</html>")
|
|
80
|
+
elif self.path.endswith(".html") or self.path in {"/", ""}:
|
|
81
|
+
content += RELOAD_SCRIPT
|
|
82
|
+
outputfile.write(content)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> None:
|
|
86
|
+
parser = argparse.ArgumentParser(description="Serve docs/ with live reload.")
|
|
87
|
+
parser.add_argument("port", nargs="?", type=int, default=8000)
|
|
88
|
+
parser.add_argument("--bind", default="127.0.0.1")
|
|
89
|
+
args = parser.parse_args()
|
|
90
|
+
|
|
91
|
+
docs_root = Path(__file__).resolve().parents[2] / "docs"
|
|
92
|
+
state = ReloadState()
|
|
93
|
+
|
|
94
|
+
observer = Observer()
|
|
95
|
+
observer.schedule(ReloadHandler(state), str(docs_root), recursive=True)
|
|
96
|
+
observer.start()
|
|
97
|
+
|
|
98
|
+
def handler_factory(*handler_args, **handler_kwargs):
|
|
99
|
+
return DocsHandler(
|
|
100
|
+
*handler_args,
|
|
101
|
+
docs_root=docs_root,
|
|
102
|
+
state=state,
|
|
103
|
+
**handler_kwargs,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
108
|
+
server = http.server.ThreadingHTTPServer(
|
|
109
|
+
(args.bind, args.port), handler_factory
|
|
110
|
+
)
|
|
111
|
+
print(f"Serving {docs_root} at http://{args.bind}:{args.port}/")
|
|
112
|
+
server.serve_forever()
|
|
113
|
+
finally:
|
|
114
|
+
observer.stop()
|
|
115
|
+
observer.join()
|
cfglock/extraction.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ext_fr_gh_public(
|
|
6
|
+
user: str | None = "phalberg",
|
|
7
|
+
repo: str | None = "configlock",
|
|
8
|
+
branch: str | None = "main",
|
|
9
|
+
file: str | None = "config.lock.json",
|
|
10
|
+
):
|
|
11
|
+
"""
|
|
12
|
+
Extract from a public github repo in GitHub, the text content
|
|
13
|
+
"""
|
|
14
|
+
raw_gh_url = "https://raw.githubusercontent.com"
|
|
15
|
+
com_url = f"{raw_gh_url}/{user}/{repo}/{branch}/{file}"
|
|
16
|
+
response = requests.get(com_url)
|
|
17
|
+
data = response.json()
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
json_data = json.dumps(ext_fr_gh_public(), indent=4)
|
|
23
|
+
with open("test_config.json", "w", encoding="utf-8") as f:
|
|
24
|
+
f.write(json_data)
|
cfglock/helper.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import yaml
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import filecmp
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from cfglock.validator import (
|
|
10
|
+
ValidationContext,
|
|
11
|
+
walk_yaml_in_order,
|
|
12
|
+
walk_yaml_with_no_order,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
load_dotenv()
|
|
16
|
+
CONFIG_LOG_FILE_PATH: str = os.environ.get("CONFIG_LOG_FILE_PATH", "config.lock.json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_file_identicality(
|
|
20
|
+
file_path: str, config_file_path: str = CONFIG_LOG_FILE_PATH
|
|
21
|
+
):
|
|
22
|
+
"""Checks if files are identical, if they are it returns True, False otherwise"""
|
|
23
|
+
try:
|
|
24
|
+
a = check_file_and_read_file(file_path)
|
|
25
|
+
b = read_json(config_file_path)
|
|
26
|
+
if a == b:
|
|
27
|
+
return True
|
|
28
|
+
filecmp.clear_cache()
|
|
29
|
+
res = filecmp.cmp(file_path, config_file_path, shallow=False)
|
|
30
|
+
return res
|
|
31
|
+
except Exception:
|
|
32
|
+
filecmp.clear_cache()
|
|
33
|
+
res = filecmp.cmp(file_path, config_file_path, shallow=False)
|
|
34
|
+
return res
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_file_exists(file_path: str = CONFIG_LOG_FILE_PATH) -> bool:
|
|
38
|
+
path = Path(file_path)
|
|
39
|
+
exists = path.exists()
|
|
40
|
+
if not exists:
|
|
41
|
+
typer.echo(f"The path does not exist: {path}")
|
|
42
|
+
return exists
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_yaml(file_path: str) -> dict:
|
|
46
|
+
try:
|
|
47
|
+
with open(file_path, "r") as f:
|
|
48
|
+
data = yaml.safe_load(f)
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
raise
|
|
51
|
+
else:
|
|
52
|
+
typer.echo("Sucessfully read file")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_json(file_path: str) -> dict:
|
|
57
|
+
try:
|
|
58
|
+
with open(file_path, "r") as f:
|
|
59
|
+
data = json.load(f)
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
raise
|
|
62
|
+
else:
|
|
63
|
+
typer.echo("Sucessfully read file")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_json(data: dict, file_path: str = CONFIG_LOG_FILE_PATH) -> None:
|
|
68
|
+
data.update({"version": 1})
|
|
69
|
+
try:
|
|
70
|
+
with open(file_path, "w") as json_file:
|
|
71
|
+
json.dump(data, json_file, indent=4)
|
|
72
|
+
except TypeError:
|
|
73
|
+
raise
|
|
74
|
+
except Exception:
|
|
75
|
+
raise
|
|
76
|
+
else:
|
|
77
|
+
typer.echo("Sucessfully wrote file")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_file_and_read_file(file_path: str) -> dict:
|
|
81
|
+
typer.echo(f"Reading {file_path}...")
|
|
82
|
+
|
|
83
|
+
path = Path(file_path)
|
|
84
|
+
suffix = path.suffix.lower()
|
|
85
|
+
|
|
86
|
+
reader_by_suffix = {
|
|
87
|
+
".yaml": read_yaml,
|
|
88
|
+
".yml": read_yaml,
|
|
89
|
+
".json": read_json,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
reader = reader_by_suffix.get(suffix)
|
|
93
|
+
if reader is None:
|
|
94
|
+
typer.echo(
|
|
95
|
+
f"File not suppported: {suffix}. Use .yaml, .yml, or .json.",
|
|
96
|
+
err=True,
|
|
97
|
+
)
|
|
98
|
+
raise ValueError("Error not able to read the file")
|
|
99
|
+
|
|
100
|
+
data = reader(file_path)
|
|
101
|
+
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_comp_cli(new_file_path: str, order_matters: bool = False) -> None:
|
|
106
|
+
""" ""
|
|
107
|
+
Check compatiblity for the cli version
|
|
108
|
+
Keys => same names (must be the same, and (!) in the same (?order?)/precedence)
|
|
109
|
+
Values => must be of the same types
|
|
110
|
+
What about adding New entries? -> Fine
|
|
111
|
+
Deleting entries should not be allowed as it will obviously destroy Things. -> Fail
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
current_file_path = CONFIG_LOG_FILE_PATH
|
|
115
|
+
context = ValidationContext(
|
|
116
|
+
new_path=new_file_path,
|
|
117
|
+
current_path=current_file_path,
|
|
118
|
+
order_matters=bool(order_matters),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
current_data = read_json(current_file_path)
|
|
122
|
+
new_data = check_file_and_read_file(new_file_path)
|
|
123
|
+
|
|
124
|
+
if order_matters:
|
|
125
|
+
walk_yaml_in_order(current_data, new_data, context)
|
|
126
|
+
else:
|
|
127
|
+
walk_yaml_with_no_order(current_data, new_data, context)
|
cfglock/test_config.json
ADDED
cfglock/validator.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from itertools import zip_longest
|
|
3
|
+
|
|
4
|
+
keys_to_ignore = {"version"}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Note:
|
|
9
|
+
This is a strict class, meaning that webassembly will use this class.
|
|
10
|
+
Beware of the contents, and try to be efficient, keep the class minimal.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ValidationContext:
|
|
16
|
+
new_path: str
|
|
17
|
+
current_path: str
|
|
18
|
+
order_matters: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# add other metadata if needed.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigLockError(Exception):
|
|
25
|
+
"""Basic Error"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message, error_code=1):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.error_code = error_code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationError(ConfigLockError):
|
|
34
|
+
"""Error for validation for syncing file"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
path,
|
|
39
|
+
expected_value,
|
|
40
|
+
actual_value,
|
|
41
|
+
message="Validation Failed",
|
|
42
|
+
order_matters=False,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(message, error_code=100)
|
|
45
|
+
self.path = path
|
|
46
|
+
self.expected_value = expected_value
|
|
47
|
+
self.actual_value = actual_value
|
|
48
|
+
self.order_matters = order_matters
|
|
49
|
+
|
|
50
|
+
def __str__(self):
|
|
51
|
+
base_msg = f"""
|
|
52
|
+
In path: {self.path}
|
|
53
|
+
Expected: {self.expected_value}
|
|
54
|
+
Found: {self.actual_value}
|
|
55
|
+
"""
|
|
56
|
+
if self.order_matters:
|
|
57
|
+
base_msg += "Additional: remember that order matters for keys!"
|
|
58
|
+
return f"{base_msg}\n(Error Code: {self.error_code})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def walk_yaml_with_no_order(
|
|
62
|
+
current_data, new_data, context: ValidationContext, depth=0
|
|
63
|
+
):
|
|
64
|
+
"""Recursively walks through a YAML-loaded object with no order."""
|
|
65
|
+
|
|
66
|
+
if isinstance(current_data, dict):
|
|
67
|
+
if not isinstance(new_data, dict):
|
|
68
|
+
accept_new_value(
|
|
69
|
+
current_value=current_data, new_value=new_data, context=context
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for curr_k, curr_v in current_data.items():
|
|
74
|
+
# specific values for our own interpretation of versionings etc.
|
|
75
|
+
if curr_k in keys_to_ignore:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if curr_k not in new_data:
|
|
79
|
+
accept_new_keys(curr_k, None, context)
|
|
80
|
+
|
|
81
|
+
new_v = new_data[curr_k]
|
|
82
|
+
|
|
83
|
+
walk_yaml_with_no_order(curr_v, new_v, context, depth + 1)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
accept_new_value(
|
|
87
|
+
current_value=current_data, new_value=new_data, context=context
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def walk_yaml_in_order(current_data, new_data, context: ValidationContext, depth=0):
|
|
92
|
+
"""Recursively walks through a YAML-loaded object in order."""
|
|
93
|
+
|
|
94
|
+
if isinstance(current_data, dict):
|
|
95
|
+
for new_pair, curr_pair in zip_longest(
|
|
96
|
+
new_data.items(), current_data.items(), fillvalue=(None, None)
|
|
97
|
+
):
|
|
98
|
+
new_k, new_v = new_pair
|
|
99
|
+
curr_k, curr_v = curr_pair
|
|
100
|
+
|
|
101
|
+
# specific values for our own interpretation of versionings etc.
|
|
102
|
+
if curr_k in keys_to_ignore:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
accept_new_keys(curr_k, new_k, context)
|
|
106
|
+
|
|
107
|
+
walk_yaml_with_no_order(new_v, curr_v, context, depth + 1)
|
|
108
|
+
|
|
109
|
+
else:
|
|
110
|
+
accept_new_value(
|
|
111
|
+
current_value=current_data, new_value=new_data, context=context
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Not sure about this solution to this problem...
|
|
116
|
+
def accept_new_keys(
|
|
117
|
+
current_key: str | None, new_key: str | None, context: ValidationContext
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
General logic for accepting new keys:
|
|
121
|
+
1) the name of new_key cannot be different than the name of current_key
|
|
122
|
+
2) the order of new_key cannot be different than the current_key
|
|
123
|
+
3) the precedence (i.e indentation) cannot be different than the current_key
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if current_key != new_key:
|
|
127
|
+
if context.order_matters:
|
|
128
|
+
raise ValidationError(
|
|
129
|
+
path=context.new_path,
|
|
130
|
+
expected_value=current_key,
|
|
131
|
+
actual_value=new_key,
|
|
132
|
+
order_matters=True,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
raise ValidationError(
|
|
136
|
+
path=context.new_path,
|
|
137
|
+
expected_value=current_key,
|
|
138
|
+
actual_value=new_key,
|
|
139
|
+
order_matters=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def accept_new_value(current_value, new_value, context: ValidationContext) -> None:
|
|
144
|
+
"""
|
|
145
|
+
General logic for accepting new values:
|
|
146
|
+
1) the type of the new_value cannot be different than the type of the current_value
|
|
147
|
+
"""
|
|
148
|
+
type_curr = type(current_value)
|
|
149
|
+
type_new = type(new_value)
|
|
150
|
+
|
|
151
|
+
if type_curr != type_new:
|
|
152
|
+
type_curr_val = f"<{type_curr.__name__}> with value: {current_value}"
|
|
153
|
+
type_next_val = f"<{type_new.__name__}> with value: {new_value}"
|
|
154
|
+
|
|
155
|
+
raise ValidationError(
|
|
156
|
+
path=context.new_path,
|
|
157
|
+
expected_value=type_curr_val,
|
|
158
|
+
actual_value=type_next_val,
|
|
159
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cfglock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python engine for the ConfigLock ecosystem
|
|
5
|
+
Author: Philipp.Stahlberg
|
|
6
|
+
Author-email: Philipp.Stahlberg <131917964+PhilippStahlbergGit@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: dotenv>=0.9.9
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Requires-Dist: requests>=2.34.2
|
|
10
|
+
Requires-Dist: typer>=0.25.1
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="https://github.com/phalberg/configlock/actions/workflows/ci-cd.yaml/badge.svg" alt="CI Status" />
|
|
16
|
+
<img src="https://img.shields.io/badge/python-3.13-blue.svg" alt="Python Version" />
|
|
17
|
+
<a href="https://codecov.io/github/phalberg/configlock" >
|
|
18
|
+
<img src="https://codecov.io/github/phalberg/configlock/graph/badge.svg?token=60JBRC22NB"/>
|
|
19
|
+
</a>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
# ConfigLock
|
|
23
|
+
ConfigLock is a lightweight CLI tool designed to prevent production outages by configuration errors. It brings the concept of Lockfiles (inspiration from uv) to your application’s .yaml or .json configurations.
|
|
24
|
+
|
|
25
|
+
# Quick start
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
# Clone the repo
|
|
29
|
+
git clone https://github.com/phalberg/configlock
|
|
30
|
+
cd configlock
|
|
31
|
+
|
|
32
|
+
# Install dependencies
|
|
33
|
+
uv sync
|
|
34
|
+
|
|
35
|
+
# Initialize a lockfile
|
|
36
|
+
uv run configlock init my_config.yaml
|
|
37
|
+
|
|
38
|
+
# Sync after changes
|
|
39
|
+
uv run configlock sync my_config.yaml
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If you wish to not write `uv` each time, you can do as such:
|
|
43
|
+
|
|
44
|
+
## CLI Usage
|
|
45
|
+
|
|
46
|
+
Install the project in editable mode while developing:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run the command directly after install:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
configlock --help
|
|
56
|
+
configlock init {path_to_your_file}
|
|
57
|
+
configlock lock {path_to_your_file}
|
|
58
|
+
configlock sync {path_to_your_file}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Preview the docs locally with hot reload:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv run docs-serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
# Status
|
|
68
|
+
ConfigLock is a **personal hobby project** focused on learning robust CLI development and structural validation logic.
|
|
69
|
+
|
|
70
|
+
> [!NOTE]
|
|
71
|
+
> This project is in an early prototype stage. It is a learning exercise in building developer tools with Python and Typer.
|
|
72
|
+
|
|
73
|
+
### Roadmap
|
|
74
|
+
- [x] Basic CLI integration with Typer
|
|
75
|
+
- [x] GitHub Actions CI/CD pipeline
|
|
76
|
+
- [x] Recursive Type & Structure checking
|
|
77
|
+
- [ ] GitHub API integration (Fetch remote configs)
|
|
78
|
+
- [ ] Web-based UI for configuration visualization
|
|
79
|
+
|
|
80
|
+
# The problem
|
|
81
|
+
In modern DevOps, non-technical team members often need to edit configuration files (YAML/JSON). One missing key or a wrong data type (e.g., entering a string where a boolean is expected) may crash a production environment.
|
|
82
|
+
|
|
83
|
+
# Init
|
|
84
|
+
```bash
|
|
85
|
+
init: Analyzes your YAML/JSON and creates a config.lock.json that stores the required structure and types.
|
|
86
|
+
```
|
|
87
|
+
_Note: ConfigLock generates one unique lockfile corresponding to the file path provided._
|
|
88
|
+
|
|
89
|
+
# Sync
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
sync: Compares your current YAML/JSON against the lockfile. If a key is missing or a type has changed, you get an error.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
# Lock
|
|
96
|
+
```bash
|
|
97
|
+
lock: Checks your current YAML/JSON and tries to replace the locked file with the new changed current file, if the change is not compatible, you get an error.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
## Lock with strict ordering
|
|
101
|
+
Please use the command:
|
|
102
|
+
```bash
|
|
103
|
+
configlock lock {path_to_your_file} --order-matters
|
|
104
|
+
```
|
|
105
|
+
If the order of the keys matter, if not the default:
|
|
106
|
+
```bash
|
|
107
|
+
configlock lock {path_to_your_file} --no-order-matters
|
|
108
|
+
```
|
|
109
|
+
will be set.
|
|
110
|
+
|
|
111
|
+
# License
|
|
112
|
+
|
|
113
|
+
This project is licensed under the terms of the MIT license.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cfglock/__init__.py,sha256=KjdmQoOkFsZDY5t8DXo9CCA2I-5wONw7SadGqnntboI,181
|
|
2
|
+
cfglock/cli.py,sha256=frYfngMZI5cR_Fu6sUc_FfU-sjYXjJ3UCeX-oIkAFSs,1693
|
|
3
|
+
cfglock/docs_server.py,sha256=RRfmqsxjWZz9lPLwQWBu6DY933OOtt0XfHS1nU-Pc3k,3464
|
|
4
|
+
cfglock/extraction.py,sha256=IRytsDxu7u_-1o_b-FndvVK_C1ll_CY7VP9B_sKJWos,678
|
|
5
|
+
cfglock/helper.py,sha256=v8zBPMvPef_wirrRr75WKecSdXhmPkC55GsfVust0ec,3511
|
|
6
|
+
cfglock/test_config.json,sha256=OrujvxqJJUMdps3HtWXU4GVcELlCNvugGNmkcYHoTRs,170
|
|
7
|
+
cfglock/validator.py,sha256=mp6quymgaEgfT-Bh2YhcTrX_GgSARQfpHBC5llQUY5Q,4850
|
|
8
|
+
cfglock-0.1.0.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
|
|
9
|
+
cfglock-0.1.0.dist-info/entry_points.txt,sha256=1JINzlj5nblXdJjS-jeU_pox2MW3WVl2lRfCbv5NF6E,134
|
|
10
|
+
cfglock-0.1.0.dist-info/METADATA,sha256=lTBsCDfVyh24eqNcYTjpK5Vpczrgi5Jg5ZsE4zzSsLM,3400
|
|
11
|
+
cfglock-0.1.0.dist-info/RECORD,,
|