sentry-infra-tools 0.0.3__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.
Files changed (94) hide show
  1. assistant/__init__.py +0 -0
  2. assistant/prdocs.py +147 -0
  3. assistant/test_prdocs.py +88 -0
  4. config_builder/__init__.py +0 -0
  5. config_builder/clean_generated.py +31 -0
  6. config_builder/combined_generator.py +170 -0
  7. config_builder/generate_topic_data.py +26 -0
  8. config_builder/json_schema_validator.py +81 -0
  9. config_builder/loaders.py +48 -0
  10. config_builder/materialize_all.py +111 -0
  11. config_builder/materializer.py +66 -0
  12. config_builder/merger/__init__.py +52 -0
  13. config_builder/merger/libsonnet.py +27 -0
  14. config_builder/merger/test_libsonnet.py +22 -0
  15. config_builder/merger/test_yaml.py +68 -0
  16. config_builder/merger/yamljson.py +36 -0
  17. config_builder/test_combined_generator.py +195 -0
  18. config_builder/test_json_schema_validator.py +226 -0
  19. config_builder/test_materializer.py +104 -0
  20. libsentrykube/__init__.py +3 -0
  21. libsentrykube/bastions.py +104 -0
  22. libsentrykube/cluster.py +80 -0
  23. libsentrykube/config.py +129 -0
  24. libsentrykube/context.py +36 -0
  25. libsentrykube/customer.py +108 -0
  26. libsentrykube/datadog.py +75 -0
  27. libsentrykube/events.py +206 -0
  28. libsentrykube/ext.py +677 -0
  29. libsentrykube/gcloud.py +102 -0
  30. libsentrykube/google_auth.py +33 -0
  31. libsentrykube/iap.py +124 -0
  32. libsentrykube/jira.py +147 -0
  33. libsentrykube/kube.py +592 -0
  34. libsentrykube/lint.py +188 -0
  35. libsentrykube/loader.py +15 -0
  36. libsentrykube/reversemap.py +199 -0
  37. libsentrykube/service.py +121 -0
  38. libsentrykube/setup.py +40 -0
  39. libsentrykube/ssh.py +42 -0
  40. libsentrykube/tests/__init__.py +0 -0
  41. libsentrykube/tests/conftest.py +22 -0
  42. libsentrykube/tests/test_cluster.py +69 -0
  43. libsentrykube/tests/test_config.py +76 -0
  44. libsentrykube/tests/test_context.py +33 -0
  45. libsentrykube/tests/test_customer.py +19 -0
  46. libsentrykube/tests/test_datadog.py +73 -0
  47. libsentrykube/tests/test_deep_merge.py +121 -0
  48. libsentrykube/tests/test_gcloud.py +42 -0
  49. libsentrykube/tests/test_jira.py +197 -0
  50. libsentrykube/tests/test_lint.py +136 -0
  51. libsentrykube/tests/test_reversemap.py +149 -0
  52. libsentrykube/toolbox.py +48 -0
  53. libsentrykube/utils.py +376 -0
  54. libsentrykube/vault.py +55 -0
  55. pr_approver/__init__.py +0 -0
  56. pr_approver/approver.py +174 -0
  57. pr_approver/gh.py +63 -0
  58. pr_approver/rules.py +77 -0
  59. pr_approver/test_approver.py +85 -0
  60. pr_approver/test_rules.py +225 -0
  61. sentry_infra_tools-0.0.3.data/scripts/sentry-kube-pop +100 -0
  62. sentry_infra_tools-0.0.3.dist-info/METADATA +80 -0
  63. sentry_infra_tools-0.0.3.dist-info/RECORD +94 -0
  64. sentry_infra_tools-0.0.3.dist-info/WHEEL +5 -0
  65. sentry_infra_tools-0.0.3.dist-info/entry_points.txt +33 -0
  66. sentry_infra_tools-0.0.3.dist-info/top_level.txt +5 -0
  67. sentry_kube/__init__.py +0 -0
  68. sentry_kube/cli/__init__.py +226 -0
  69. sentry_kube/cli/apply.py +512 -0
  70. sentry_kube/cli/audit.py +131 -0
  71. sentry_kube/cli/cluster.py +110 -0
  72. sentry_kube/cli/connect.py +195 -0
  73. sentry_kube/cli/datadog.py +59 -0
  74. sentry_kube/cli/detect_drift.py +80 -0
  75. sentry_kube/cli/edit_secret.py +97 -0
  76. sentry_kube/cli/get_context.py +13 -0
  77. sentry_kube/cli/get_customers.py +13 -0
  78. sentry_kube/cli/k9s.py +45 -0
  79. sentry_kube/cli/kafkactl.py +75 -0
  80. sentry_kube/cli/kubectl.py +95 -0
  81. sentry_kube/cli/rendervalues.py +34 -0
  82. sentry_kube/cli/resolve_pvc.py +107 -0
  83. sentry_kube/cli/restart.py +73 -0
  84. sentry_kube/cli/run_job.py +269 -0
  85. sentry_kube/cli/run_pod.py +419 -0
  86. sentry_kube/cli/scale.py +233 -0
  87. sentry_kube/cli/scp.py +52 -0
  88. sentry_kube/cli/ssh.py +33 -0
  89. sentry_kube/cli/toolbox.py +28 -0
  90. sentry_kube/cli/tunnel.py +131 -0
  91. sentry_kube/cli/validate.py +75 -0
  92. sentry_kube/ext.py +255 -0
  93. sentry_kube/render_services.py +70 -0
  94. sentry_kube/validate_services.py +83 -0
assistant/__init__.py ADDED
File without changes
assistant/prdocs.py ADDED
@@ -0,0 +1,147 @@
1
+ import argparse
2
+ import sys
3
+ from dataclasses import dataclass
4
+ from json import loads
5
+ from pathlib import Path
6
+ from typing import MutableMapping, MutableSet, Sequence
7
+
8
+ COMMENT_TEMPLATE = """# Ops Assistant
9
+
10
+ The ops repo is a large monorepo and there are multiple processes
11
+ to deploy its content depending on what is changed.
12
+ This message is here to provide context.
13
+
14
+ {content}
15
+ """
16
+
17
+ INSTRUCTION_TEMPLATE = """
18
+ {content}
19
+
20
+ Files changed:
21
+ ```
22
+ {files}
23
+ ```
24
+ ---
25
+ """
26
+
27
+ INSTRUCTIONS_FILE = "deploy_instructions.md"
28
+ INSTRUCTIONS_CONF_FILE = "deploy_instructions.json"
29
+
30
+
31
+ @dataclass
32
+ class Instruction:
33
+ paths: MutableSet[Path]
34
+ text: str
35
+
36
+
37
+ class InstructionsMessage:
38
+ """
39
+ Accumulates instructions to add to a comment in a PR.
40
+
41
+ Given a list of paths (touched by the PR) this scans all its
42
+ parent directories looking for instructions on how to deploy
43
+ the change contained in the file.
44
+
45
+ It then merges all instructions into one message.
46
+ """
47
+
48
+ def __init__(self, root: Path) -> None:
49
+ # The root directory where the repo is.
50
+ self.__root = root.resolve()
51
+ self.__instructions: MutableMapping[Path, Instruction] = {}
52
+
53
+ def __fetch_instruction(self, path: Path, updated_file: Path) -> None:
54
+ """
55
+ Looks for a `deploy_instructions.md` file to add to the comment.
56
+
57
+ Many directories should share the same instructions (think about
58
+ the k8s services deployable with GoCD). For that there is the
59
+ `deploy_instructions.json` file that contains a reference to a
60
+ markdown instructions file.
61
+
62
+ This file will also be used to provide more sophisticated
63
+ directives on how to compose the message.
64
+ """
65
+ config_path = path / INSTRUCTIONS_CONF_FILE
66
+ instructions_path = path / INSTRUCTIONS_FILE
67
+
68
+ if config_path.exists() and config_path.is_file():
69
+ conf = loads(config_path.read_text())
70
+ assert isinstance(conf, MutableMapping), f"Invalid content of {INSTRUCTIONS_CONF_FILE}"
71
+ ref_path = conf.get("ref")
72
+ if ref_path:
73
+ instructions_path = path / ref_path
74
+
75
+ if instructions_path.exists() and instructions_path.is_file():
76
+ content = instructions_path.read_text()
77
+
78
+ instruction_obj = self.__instructions.get(instructions_path.resolve())
79
+ if instruction_obj is None:
80
+ instruction_obj = Instruction({updated_file}, content)
81
+ self.__instructions[instructions_path] = instruction_obj
82
+ else:
83
+ instruction_obj.paths.add(updated_file)
84
+
85
+ def add_path(self, path: Path) -> None:
86
+ path = path.resolve()
87
+ if self.__root not in path.parents:
88
+ return
89
+
90
+ updated_file = path
91
+ if not path.is_dir():
92
+ path = path.parent
93
+
94
+ while path != self.__root:
95
+ self.__fetch_instruction(path, updated_file)
96
+ path = path.parent
97
+
98
+ def produce_message(self) -> str | None:
99
+ if not self.__instructions:
100
+ return None
101
+
102
+ instructions_content = ""
103
+ for instruction in self.__instructions.values():
104
+ files = "".join(sorted(str(p) for p in instruction.paths))
105
+ instructions_content += INSTRUCTION_TEMPLATE.format(
106
+ **{"files": files, "content": instruction.text}
107
+ )
108
+
109
+ return COMMENT_TEMPLATE.format(**{"content": instructions_content})
110
+
111
+
112
+ def main(argv: Sequence[str] | None = None) -> None:
113
+ parser = argparse.ArgumentParser(
114
+ description="""
115
+ Collects all the relevant documentation to deploy the changes
116
+ in a pr given the list of involved files. It, then, produces a single
117
+ message to attach to the PR itself.
118
+ """
119
+ )
120
+ parser.add_argument(
121
+ "-r",
122
+ "--root",
123
+ action="store",
124
+ help="""
125
+ The root directory of all changes
126
+ """,
127
+ )
128
+
129
+ args = parser.parse_args()
130
+
131
+ root = args.root
132
+ assert (
133
+ root is not None and Path(root).exists() and Path(root).is_dir()
134
+ ), "The root path does not exists or is not a directory."
135
+ message = InstructionsMessage(Path(root))
136
+
137
+ for path in sys.stdin:
138
+ print(f"Processing {path}", file=sys.stderr)
139
+ message.add_path(Path(path))
140
+
141
+ response = message.produce_message()
142
+ if response is not None:
143
+ print(response)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,88 @@
1
+ import os
2
+ import tempfile
3
+ from json import dumps
4
+ from pathlib import Path
5
+ from typing import Generator
6
+
7
+ import pytest
8
+
9
+ from assistant.prdocs import InstructionsMessage
10
+
11
+
12
+ @pytest.fixture
13
+ def valid_structure() -> Generator[str, None, None]:
14
+ with tempfile.TemporaryDirectory() as temp_dir:
15
+ full_path1 = Path(temp_dir) / "shared_config/kafka/generated_files"
16
+ full_path2 = Path(temp_dir) / "shared_config/kafka/other_files"
17
+ full_path3 = Path(temp_dir) / "shared_config/kafka/more_files"
18
+ os.makedirs(full_path1)
19
+ os.makedirs(full_path2)
20
+ os.makedirs(full_path3)
21
+
22
+ with open(full_path1 / "topic1.yaml", "w") as file:
23
+ file.write("something")
24
+
25
+ with open(full_path1 / "topic2.yaml", "w") as file:
26
+ file.write("something")
27
+
28
+ with open(full_path1 / "deploy_instructions.md", "w") as file:
29
+ file.write("Kafka content")
30
+
31
+ with open(full_path2 / "topic3.yaml", "w") as file:
32
+ file.write("something")
33
+
34
+ with open(full_path2 / "deploy_instructions.json", "w") as file:
35
+ file.write(dumps({"ref": str(full_path1 / "deploy_instructions.md")}))
36
+
37
+ yield temp_dir
38
+
39
+
40
+ def test_empty(valid_structure) -> None:
41
+ message = InstructionsMessage(Path(valid_structure))
42
+ message.add_path(Path(valid_structure) / "shared_config/kafka/more_files/something.yaml")
43
+
44
+ assert message.produce_message() is None
45
+
46
+
47
+ def test_full_message(valid_structure) -> None:
48
+ message = InstructionsMessage(Path(valid_structure))
49
+ message.add_path(Path(valid_structure) / "shared_config/kafka/generated_files/topic1.yaml")
50
+ message.add_path(Path(valid_structure) / "shared_config/kafka/generated_files/topic2.yaml")
51
+
52
+ two_files_comment = f"""# Ops Assistant
53
+
54
+ The ops repo is a large monorepo and there are multiple processes
55
+ to deploy its content depending on what is changed.
56
+ This message is here to provide context.
57
+
58
+
59
+ Kafka content
60
+
61
+ Files changed:
62
+ ```
63
+ {Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic1.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic2.yaml"}
64
+ ```
65
+ ---
66
+
67
+ """
68
+ assert message.produce_message() == two_files_comment
69
+
70
+ message.add_path(Path(valid_structure) / "shared_config/kafka/other_files/topic3.yaml")
71
+
72
+ three_files_comment = f"""# Ops Assistant
73
+
74
+ The ops repo is a large monorepo and there are multiple processes
75
+ to deploy its content depending on what is changed.
76
+ This message is here to provide context.
77
+
78
+
79
+ Kafka content
80
+
81
+ Files changed:
82
+ ```
83
+ {Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic1.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic2.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/other_files/topic3.yaml"}
84
+ ```
85
+ ---
86
+
87
+ """
88
+ assert message.produce_message() == three_files_comment
File without changes
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ from config_builder.combined_generator import clean_all as clean_generated
8
+
9
+
10
+ def main(argv: Sequence[str] | None = None) -> None:
11
+ parser = argparse.ArgumentParser(
12
+ description="""
13
+ Clean up all generated and materialized files.
14
+ """
15
+ )
16
+ parser.add_argument(
17
+ "root_dir",
18
+ type=str,
19
+ action="store",
20
+ help="""
21
+ The root directory of the shared configs.
22
+ """,
23
+ )
24
+
25
+ args = parser.parse_args(argv)
26
+ materialized_dir = Path(args.root_dir)
27
+ clean_generated(materialized_dir)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,170 @@
1
+ import os
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ from shutil import rmtree
5
+ from typing import Generator, Sequence, Tuple
6
+
7
+ from config_builder.loaders import YamlFileLoader
8
+ from config_builder.merger import FileMerger
9
+ from config_builder.merger.libsonnet import LibsonnetMerger
10
+ from config_builder.merger.yamljson import YamlMerger
11
+
12
+ from config_builder.json_schema_validator import JsonSchemaValidator
13
+
14
+ DEFAULT_LIBSONNET_OUTPUT_FILE_NAME = "_generated.libsonnet"
15
+ DEFAULT_JSON_OUTPUT_FILE_NAME = "_generated.json"
16
+ CONFIG_GENERATOR_SETTINGS = "_config_generator.json"
17
+ GENERATED_DIR = "generated"
18
+
19
+
20
+ class Outcome(Enum):
21
+ NEW = "new"
22
+ UNCHANGED = "unchanged"
23
+ UPDATED = "updated"
24
+
25
+
26
+ def combined_file_name(root_directory: Path, output_file_name: str) -> Path:
27
+ return root_directory / GENERATED_DIR / output_file_name
28
+
29
+
30
+ def combine_files(
31
+ merger: FileMerger,
32
+ root_directory: Path,
33
+ ) -> str:
34
+ """
35
+ Combine all the files in `root_directory` using the FileMerger
36
+ provided.
37
+ """
38
+ assert root_directory.is_dir(), "Root directory must be a directory"
39
+
40
+ input_files = [x for x in Path(root_directory).iterdir() if x.is_file()]
41
+ for f in input_files:
42
+ merger.add_file(f)
43
+
44
+ return merger.serialize_content()
45
+
46
+
47
+ def validate_schema(validator: JsonSchemaValidator, root_directory: Path) -> None:
48
+ input_files = [x for x in Path(root_directory).iterdir() if x.is_file()]
49
+ for f in input_files:
50
+ if str(f).endswith(("yaml", "yml")):
51
+ validator.validate_yaml(f)
52
+
53
+
54
+ def combine_and_write(
55
+ merger: FileMerger,
56
+ root_directory: Path,
57
+ output_file_name: str,
58
+ ) -> Tuple[Outcome, Path]:
59
+ """
60
+ Generates a unified file from the all the yaml/json/libsonnet files
61
+ contained in a directory.
62
+
63
+ A FileMerger is provided that is able to merge files of different types.
64
+ There are two implementations: one for yaml/json and the other for
65
+ libsonnet files.
66
+
67
+ The output is written in a predefined location `root_dir/generated` if
68
+ the generated file is in a ny way different from the pre-existing
69
+ combined import file in such directory.
70
+
71
+ It return whether the file was unchanged, new or updated.
72
+ """
73
+ generated_file = combine_files(merger, root_directory)
74
+ if not (root_directory / GENERATED_DIR).exists():
75
+ os.makedirs(root_directory / GENERATED_DIR)
76
+
77
+ generated_file_path = combined_file_name(root_directory, output_file_name)
78
+ if not generated_file_path.exists():
79
+ outcome = Outcome.NEW
80
+ else:
81
+ with open(generated_file_path) as f:
82
+ existing_content = f.read()
83
+ outcome = Outcome.UNCHANGED if existing_content == generated_file else Outcome.UPDATED
84
+
85
+ if outcome != Outcome.UNCHANGED:
86
+ with open(generated_file_path, "w") as f:
87
+ f.write(generated_file)
88
+
89
+ return (outcome, generated_file_path)
90
+
91
+
92
+ def iterate_config_directories(root_dir: Path) -> Generator[Path, None, None]:
93
+ """
94
+ Finds all the subdirectories of `root_dir` that are suitable for jsonnet
95
+ import file generation.
96
+
97
+ Such directories contain:
98
+ - a `_config_generator.json` file to mark it
99
+ - a `generated` subdirectory where the generated file will be (if it doesn't exsit, it'll get created in the upcoming combine_and_write step)
100
+ """
101
+ for item in root_dir.rglob("*"):
102
+ if item.is_dir() and (item / CONFIG_GENERATOR_SETTINGS).exists():
103
+ yield item
104
+
105
+
106
+ def iterate_roots_and_regions(
107
+ root_dir: Path,
108
+ ) -> Generator[Tuple[Path, bool | None], None, None]:
109
+ """
110
+ Extends `iterate_config_directories` by managing properly region
111
+ subdirectories.
112
+
113
+ If a root_directory (a directory with containing a CONFIG_GENERATOR_SETTINGS
114
+ file) contains a `regional_overrides` subdirectory, the root_directory
115
+ is also returned, as well as each dir inside 'regional_overrides'.
116
+ """
117
+
118
+ for config_root in iterate_config_directories(root_dir):
119
+ yield (config_root, False)
120
+ regions_path = Path(config_root / "regional_overrides")
121
+ if regions_path.exists() and regions_path.is_dir():
122
+ for region_root in regions_path.iterdir():
123
+ if region_root.is_dir():
124
+ yield (region_root, True)
125
+
126
+
127
+ def validate_all_files(root_directory: Path) -> None:
128
+ validator = JsonSchemaValidator()
129
+ for config_root, _ in iterate_roots_and_regions(root_directory):
130
+ input_files = [
131
+ x
132
+ for x in Path(config_root).iterdir()
133
+ if x.is_file() and str(x).endswith(("yaml", "yml"))
134
+ ]
135
+ for f in input_files:
136
+ validator.validate_yaml(f)
137
+
138
+
139
+ def generate_all_files(root_directory: Path) -> Sequence[Tuple[Outcome, Path]]:
140
+ """
141
+ Scan all the directories under `root_directory` to find directories
142
+ that has to be treated as root configs, thus requiring us to combine
143
+ multiple files into one to be imported by a jsonnet script.
144
+ """
145
+ ret = []
146
+
147
+ for config_root, _ in iterate_roots_and_regions(root_directory):
148
+ outcome, output_file_name = combine_and_write(
149
+ LibsonnetMerger(), config_root, DEFAULT_LIBSONNET_OUTPUT_FILE_NAME
150
+ )
151
+ ret.append((outcome, output_file_name))
152
+
153
+ content_loader = YamlFileLoader(config_root)
154
+ outcome, output_file_name = combine_and_write(
155
+ YamlMerger(CONFIG_GENERATOR_SETTINGS, content_loader),
156
+ config_root,
157
+ DEFAULT_JSON_OUTPUT_FILE_NAME,
158
+ )
159
+ ret.append((outcome, output_file_name))
160
+ return ret
161
+
162
+
163
+ def clean_all(root_directory: Path) -> None:
164
+ """
165
+ Deletes all the generated files.
166
+ """
167
+ for config_root, _ in iterate_roots_and_regions(root_directory):
168
+ if (config_root / GENERATED_DIR).exists():
169
+ assert (config_root / GENERATED_DIR).is_dir()
170
+ rmtree(config_root / GENERATED_DIR)
@@ -0,0 +1,26 @@
1
+ import os
2
+ import json
3
+ from sys import stderr
4
+ from sentry_kafka_schemas import get_topic, list_topics
5
+ from pathlib import Path
6
+
7
+
8
+ def main() -> None:
9
+ root = Path(os.path.abspath(__file__)).parent.parent.parent.parent
10
+ output = f"{root}/shared_config/kafka/topics/generated/_generated_raw_topic_data.json"
11
+
12
+ print("Generating raw topic data", file=stderr)
13
+ file_path = Path(output)
14
+
15
+ topic_data = {
16
+ name: {"topic_creation_config": get_topic(name)["topic_creation_config"]}
17
+ for name in list_topics()
18
+ }
19
+
20
+ os.makedirs(file_path.parent, exist_ok=True)
21
+ with open(file_path, "w") as f:
22
+ f.write(json.dumps(topic_data, indent=2) + "\n")
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,81 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Mapping, Optional
4
+
5
+ import jsonschema
6
+ import yaml
7
+ from jsonschema.exceptions import ValidationError
8
+ from referencing import Registry, Resource
9
+
10
+ # TODO: this should not be hardcoded. Find a dynamic way to specify schema files, maybe in _config_generator.json?
11
+ SCHEMAS = {
12
+ "kafka/topics/regional_overrides/*/*.yaml": "kafka_topic_overrides.schema.json",
13
+ "kafka/consumer_groups/regional_overrides/*/*.yaml": "consumer_groups/regional_override_consumer_group.schema.json",
14
+ "kafka/consumer_groups/*.yaml": "consumer_groups/default_consumer_group.schema.json",
15
+ "kafka/clusters/*.yaml": "kafka_clusters.schema.json",
16
+ "kafka/consumer_types/*.yaml": "consumer_groups/consumer_type.schema.json",
17
+ }
18
+
19
+ ROOT = Path("shared_config")
20
+ SCHEMAS_DIR = "schemas"
21
+ REGIONAL_OVERRIDE_DIR = "regional_overrides"
22
+
23
+
24
+ class ValidationException(Exception):
25
+ def __init__(self, file: str, schema: str):
26
+ self.file = file
27
+ self.schema = schema
28
+
29
+
30
+ class JsonSchemaValidator:
31
+ def __init__(
32
+ self,
33
+ root: Optional[Path] = None,
34
+ schemas: Optional[Mapping[str, str]] = None,
35
+ schemas_dir: Optional[str] = None,
36
+ ) -> None:
37
+ self.root = ROOT if root is None else root
38
+ self.schema_root = (
39
+ (self.root / SCHEMAS_DIR) if schemas_dir is None else (self.root / schemas_dir)
40
+ )
41
+ self.schemas = SCHEMAS if schemas is None else schemas
42
+ self.registry = self.__build_registry()
43
+
44
+ def __build_registry(self) -> Registry[Mapping[str, Resource[Mapping[str, Any]]]]:
45
+ """
46
+ The jsonschema's concept of registry is for $ref keyword to
47
+ resolve file paths and find the referenced object
48
+ """
49
+
50
+ def retrieve_ref(ref: str) -> Resource[Mapping[str, Any]]:
51
+ ref_file = self.schema_root / Path(ref)
52
+ contents = json.loads(ref_file.read_text())
53
+ return Resource.from_contents(contents)
54
+
55
+ # library bug, typing of this argument is wrong
56
+ registry: Registry = Registry(retrieve=retrieve_ref) # type: ignore
57
+ return registry
58
+
59
+ def __get_schema(self, file: Path) -> Path | None:
60
+ """
61
+ Finds which schema to use for a yaml file
62
+ """
63
+ for glob, schema in self.schemas.items():
64
+ if file.match(str(self.root / glob)):
65
+ return self.schema_root / schema
66
+ return None
67
+
68
+ def validate_yaml(self, file: Path) -> int | None:
69
+ schema = self.__get_schema(file)
70
+ if schema:
71
+ file_content = yaml.safe_load(file.read_text())
72
+ try:
73
+ jsonschema.validate(
74
+ schema=json.load(open(schema)),
75
+ instance=file_content,
76
+ registry=self.registry,
77
+ )
78
+ return 0
79
+ except ValidationError as e:
80
+ raise ValidationException(str(file), str(schema)) from e
81
+ return None
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any, Mapping, cast
6
+
7
+ from yaml import safe_load
8
+
9
+
10
+ class ContentLoader(ABC):
11
+ """
12
+ Abstractions that allow the config materializers to load content
13
+ from different sources, being them directories or external
14
+ packages.
15
+
16
+ Instances of this class can be reused.
17
+ """
18
+
19
+ @abstractmethod
20
+ def load_dict(self, file_name: str) -> Mapping[str, Any] | None:
21
+ """
22
+ Loads a dictionary from a a source. The file name can be an actual
23
+ file on disk or something logical representing a file.
24
+ `file_name` should contain the extension.
25
+
26
+ If a non existing file is asked for, this returns None. It does not
27
+ fail.
28
+ """
29
+ raise NotImplementedError
30
+
31
+
32
+ class YamlFileLoader(ContentLoader):
33
+ """
34
+ Loader that loads simple yaml file from a directory
35
+ """
36
+
37
+ def __init__(self, directory: Path) -> None:
38
+ assert (
39
+ directory.exists() and directory.is_dir()
40
+ ), f"The provided path {directory} does not exists or it is not a directory"
41
+ self.__directory = directory
42
+
43
+ def load_dict(self, file_name: str) -> Mapping[str, Any] | None:
44
+ if (self.__directory / file_name).exists():
45
+ with open(self.__directory / file_name) as content:
46
+ return cast(Mapping[str, Any], safe_load(content))
47
+
48
+ return None