esgvoc 2.0.2__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 (147) hide show
  1. esgvoc/__init__.py +3 -0
  2. esgvoc/api/__init__.py +91 -0
  3. esgvoc/api/data_descriptors/EMD_models/__init__.py +66 -0
  4. esgvoc/api/data_descriptors/EMD_models/arrangement.py +21 -0
  5. esgvoc/api/data_descriptors/EMD_models/calendar.py +5 -0
  6. esgvoc/api/data_descriptors/EMD_models/cell_variable_type.py +20 -0
  7. esgvoc/api/data_descriptors/EMD_models/component_type.py +5 -0
  8. esgvoc/api/data_descriptors/EMD_models/coordinate.py +52 -0
  9. esgvoc/api/data_descriptors/EMD_models/grid_mapping.py +19 -0
  10. esgvoc/api/data_descriptors/EMD_models/grid_region.py +19 -0
  11. esgvoc/api/data_descriptors/EMD_models/grid_type.py +19 -0
  12. esgvoc/api/data_descriptors/EMD_models/horizontal_computational_grid.py +56 -0
  13. esgvoc/api/data_descriptors/EMD_models/horizontal_grid_cells.py +230 -0
  14. esgvoc/api/data_descriptors/EMD_models/horizontal_subgrid.py +41 -0
  15. esgvoc/api/data_descriptors/EMD_models/horizontal_units.py +5 -0
  16. esgvoc/api/data_descriptors/EMD_models/model.py +139 -0
  17. esgvoc/api/data_descriptors/EMD_models/model_component.py +115 -0
  18. esgvoc/api/data_descriptors/EMD_models/reference.py +61 -0
  19. esgvoc/api/data_descriptors/EMD_models/resolution.py +48 -0
  20. esgvoc/api/data_descriptors/EMD_models/temporal_refinement.py +19 -0
  21. esgvoc/api/data_descriptors/EMD_models/truncation_method.py +17 -0
  22. esgvoc/api/data_descriptors/EMD_models/vertical_computational_grid.py +91 -0
  23. esgvoc/api/data_descriptors/EMD_models/vertical_coordinate.py +5 -0
  24. esgvoc/api/data_descriptors/EMD_models/vertical_units.py +19 -0
  25. esgvoc/api/data_descriptors/__init__.py +159 -0
  26. esgvoc/api/data_descriptors/activity.py +72 -0
  27. esgvoc/api/data_descriptors/archive.py +5 -0
  28. esgvoc/api/data_descriptors/area_label.py +30 -0
  29. esgvoc/api/data_descriptors/branded_suffix.py +30 -0
  30. esgvoc/api/data_descriptors/branded_variable.py +21 -0
  31. esgvoc/api/data_descriptors/citation_url.py +5 -0
  32. esgvoc/api/data_descriptors/contact.py +5 -0
  33. esgvoc/api/data_descriptors/conventions.py +28 -0
  34. esgvoc/api/data_descriptors/creation_date.py +18 -0
  35. esgvoc/api/data_descriptors/data_descriptor.py +127 -0
  36. esgvoc/api/data_descriptors/data_specs_version.py +25 -0
  37. esgvoc/api/data_descriptors/date.py +5 -0
  38. esgvoc/api/data_descriptors/directory_date.py +22 -0
  39. esgvoc/api/data_descriptors/drs_specs.py +38 -0
  40. esgvoc/api/data_descriptors/experiment.py +215 -0
  41. esgvoc/api/data_descriptors/forcing_index.py +21 -0
  42. esgvoc/api/data_descriptors/frequency.py +48 -0
  43. esgvoc/api/data_descriptors/further_info_url.py +5 -0
  44. esgvoc/api/data_descriptors/grid.py +43 -0
  45. esgvoc/api/data_descriptors/horizontal_label.py +20 -0
  46. esgvoc/api/data_descriptors/initialization_index.py +27 -0
  47. esgvoc/api/data_descriptors/institution.py +80 -0
  48. esgvoc/api/data_descriptors/known_branded_variable.py +75 -0
  49. esgvoc/api/data_descriptors/license.py +31 -0
  50. esgvoc/api/data_descriptors/member_id.py +9 -0
  51. esgvoc/api/data_descriptors/mip_era.py +26 -0
  52. esgvoc/api/data_descriptors/model_component.py +32 -0
  53. esgvoc/api/data_descriptors/models_test/models.py +17 -0
  54. esgvoc/api/data_descriptors/nominal_resolution.py +50 -0
  55. esgvoc/api/data_descriptors/obs_type.py +5 -0
  56. esgvoc/api/data_descriptors/organisation.py +22 -0
  57. esgvoc/api/data_descriptors/physics_index.py +21 -0
  58. esgvoc/api/data_descriptors/product.py +16 -0
  59. esgvoc/api/data_descriptors/publication_status.py +5 -0
  60. esgvoc/api/data_descriptors/realization_index.py +24 -0
  61. esgvoc/api/data_descriptors/realm.py +16 -0
  62. esgvoc/api/data_descriptors/regex.py +5 -0
  63. esgvoc/api/data_descriptors/region.py +35 -0
  64. esgvoc/api/data_descriptors/resolution.py +7 -0
  65. esgvoc/api/data_descriptors/source.py +120 -0
  66. esgvoc/api/data_descriptors/source_type.py +5 -0
  67. esgvoc/api/data_descriptors/sub_experiment.py +5 -0
  68. esgvoc/api/data_descriptors/table.py +28 -0
  69. esgvoc/api/data_descriptors/temporal_label.py +20 -0
  70. esgvoc/api/data_descriptors/time_range.py +17 -0
  71. esgvoc/api/data_descriptors/title.py +5 -0
  72. esgvoc/api/data_descriptors/tracking_id.py +67 -0
  73. esgvoc/api/data_descriptors/variable.py +56 -0
  74. esgvoc/api/data_descriptors/variant_label.py +25 -0
  75. esgvoc/api/data_descriptors/vertical_label.py +20 -0
  76. esgvoc/api/project_specs.py +143 -0
  77. esgvoc/api/projects.py +1253 -0
  78. esgvoc/api/py.typed +0 -0
  79. esgvoc/api/pydantic_handler.py +146 -0
  80. esgvoc/api/report.py +127 -0
  81. esgvoc/api/search.py +171 -0
  82. esgvoc/api/universe.py +434 -0
  83. esgvoc/apps/__init__.py +6 -0
  84. esgvoc/apps/cmor_tables/__init__.py +7 -0
  85. esgvoc/apps/cmor_tables/cvs_table.py +948 -0
  86. esgvoc/apps/drs/__init__.py +0 -0
  87. esgvoc/apps/drs/constants.py +2 -0
  88. esgvoc/apps/drs/generator.py +429 -0
  89. esgvoc/apps/drs/report.py +540 -0
  90. esgvoc/apps/drs/validator.py +312 -0
  91. esgvoc/apps/ga/__init__.py +104 -0
  92. esgvoc/apps/ga/example_usage.py +315 -0
  93. esgvoc/apps/ga/models/__init__.py +47 -0
  94. esgvoc/apps/ga/models/netcdf_header.py +306 -0
  95. esgvoc/apps/ga/models/validator.py +491 -0
  96. esgvoc/apps/ga/test_ga.py +161 -0
  97. esgvoc/apps/ga/validator.py +277 -0
  98. esgvoc/apps/jsg/json_schema_generator.py +341 -0
  99. esgvoc/apps/jsg/templates/template.jinja +241 -0
  100. esgvoc/apps/test_cv/README.md +214 -0
  101. esgvoc/apps/test_cv/__init__.py +0 -0
  102. esgvoc/apps/test_cv/cv_tester.py +1611 -0
  103. esgvoc/apps/test_cv/example_usage.py +216 -0
  104. esgvoc/apps/vr/__init__.py +12 -0
  105. esgvoc/apps/vr/build_variable_registry.py +71 -0
  106. esgvoc/apps/vr/example_usage.py +60 -0
  107. esgvoc/apps/vr/vr_app.py +333 -0
  108. esgvoc/cli/clean.py +304 -0
  109. esgvoc/cli/cmor.py +46 -0
  110. esgvoc/cli/config.py +1300 -0
  111. esgvoc/cli/drs.py +267 -0
  112. esgvoc/cli/find.py +138 -0
  113. esgvoc/cli/get.py +155 -0
  114. esgvoc/cli/install.py +41 -0
  115. esgvoc/cli/main.py +60 -0
  116. esgvoc/cli/offline.py +269 -0
  117. esgvoc/cli/status.py +79 -0
  118. esgvoc/cli/test_cv.py +258 -0
  119. esgvoc/cli/valid.py +147 -0
  120. esgvoc/core/constants.py +17 -0
  121. esgvoc/core/convert.py +0 -0
  122. esgvoc/core/data_handler.py +206 -0
  123. esgvoc/core/db/__init__.py +3 -0
  124. esgvoc/core/db/connection.py +40 -0
  125. esgvoc/core/db/models/mixins.py +25 -0
  126. esgvoc/core/db/models/project.py +102 -0
  127. esgvoc/core/db/models/universe.py +98 -0
  128. esgvoc/core/db/project_ingestion.py +231 -0
  129. esgvoc/core/db/universe_ingestion.py +172 -0
  130. esgvoc/core/exceptions.py +33 -0
  131. esgvoc/core/logging_handler.py +26 -0
  132. esgvoc/core/repo_fetcher.py +345 -0
  133. esgvoc/core/service/__init__.py +41 -0
  134. esgvoc/core/service/configuration/config_manager.py +196 -0
  135. esgvoc/core/service/configuration/setting.py +363 -0
  136. esgvoc/core/service/data_merger.py +634 -0
  137. esgvoc/core/service/esg_voc.py +77 -0
  138. esgvoc/core/service/resolver_config.py +56 -0
  139. esgvoc/core/service/state.py +324 -0
  140. esgvoc/core/service/string_heuristics.py +98 -0
  141. esgvoc/core/service/term_cache.py +108 -0
  142. esgvoc/core/service/uri_resolver.py +133 -0
  143. esgvoc-2.0.2.dist-info/METADATA +82 -0
  144. esgvoc-2.0.2.dist-info/RECORD +147 -0
  145. esgvoc-2.0.2.dist-info/WHEEL +4 -0
  146. esgvoc-2.0.2.dist-info/entry_points.txt +2 -0
  147. esgvoc-2.0.2.dist-info/licenses/LICENSE.txt +519 -0
@@ -0,0 +1,345 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ import requests
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ @contextmanager
16
+ def redirect_stdout_to_log(level=logging.INFO):
17
+ """
18
+ Redirect stdout to the global _LOGGER temporarily.
19
+ """
20
+
21
+ class StreamToLogger:
22
+ def __init__(self, log_level):
23
+ self.log_level = log_level
24
+
25
+ def write(self, message):
26
+ if message.strip(): # Avoid logging empty lines
27
+ _LOGGER.debug(self.log_level, message.strip())
28
+
29
+ def flush(self):
30
+ pass # No-op for compatibility
31
+
32
+ old_stdout = sys.stdout
33
+ old_stderr = sys.stderr
34
+ sys.stdout = StreamToLogger(level)
35
+ sys.stderr = StreamToLogger(level)
36
+ try:
37
+ yield
38
+ finally:
39
+ sys.stdout = old_stdout
40
+ sys.stderr = old_stderr
41
+
42
+
43
+ class GitHubRepository(BaseModel):
44
+ id: int
45
+ name: str
46
+ full_name: str
47
+ description: Optional[str]
48
+ html_url: str
49
+ stargazers_count: int
50
+ forks_count: int
51
+ language: Optional[str]
52
+ created_at: str
53
+ updated_at: str
54
+
55
+
56
+ class GitHubBranch(BaseModel):
57
+ name: str
58
+ commit: dict
59
+ protected: bool
60
+
61
+
62
+ class RepoFetcher:
63
+ """
64
+ DataFetcher is responsible for fetching data from external sources such as GitHub.
65
+ """
66
+
67
+ def __init__(self, base_url: str = "https://api.github.com", local_path: str = ".cache/repos", offline_mode: bool = False):
68
+ self.base_url = base_url
69
+ self.repo_dir = local_path
70
+ self.offline_mode = offline_mode
71
+
72
+ def fetch_repositories(self, user: str) -> List[GitHubRepository]:
73
+ """
74
+ Fetch repositories of a given GitHub user.
75
+ :param user: GitHub username
76
+ :return: List of GitHubRepository objects
77
+ """
78
+ if self.offline_mode:
79
+ raise Exception("Cannot fetch repositories in offline mode")
80
+
81
+ url = f"{self.base_url}/users/{user}/repos"
82
+ response = requests.get(url)
83
+
84
+ if response.status_code != 200:
85
+ raise Exception(f"Failed to fetch data: {response.status_code} - {response.text}")
86
+
87
+ try:
88
+ data = response.json()
89
+ return [GitHubRepository(**repo) for repo in data]
90
+ except ValidationError as e:
91
+ raise Exception(f"Data validation error: {e}")
92
+
93
+ def fetch_repository_details(self, owner: str, repo: str) -> GitHubRepository:
94
+ """
95
+ Fetch details of a specific repository.
96
+ :param owner: Repository owner
97
+ :param repo: Repository name
98
+ :return: GitHubRepository object
99
+ """
100
+ if self.offline_mode:
101
+ raise Exception("Cannot fetch repository details in offline mode")
102
+
103
+ url = f"{self.base_url}/repos/{owner}/{repo}"
104
+ response = requests.get(url)
105
+
106
+ if response.status_code != 200:
107
+ raise Exception(f"Failed to fetch data: {response.status_code} - {response.text}")
108
+
109
+ try:
110
+ data = response.json()
111
+ return GitHubRepository(**data)
112
+ except ValidationError as e:
113
+ raise Exception(f"Data validation error: {e}")
114
+
115
+ def fetch_branch_details(self, owner: str, repo: str, branch: str) -> GitHubBranch:
116
+ """
117
+ Fetch details of a specific branch in a repository.
118
+ :param owner: Repository owner
119
+ :param repo: Repository name
120
+ :param branch: Branch name
121
+ :return: GitHubBranch object
122
+ """
123
+ if self.offline_mode:
124
+ raise Exception("Cannot fetch branch details in offline mode")
125
+
126
+ url = f"{self.base_url}/repos/{owner}/{repo}/branches/{branch}"
127
+ response = requests.get(url)
128
+
129
+ if response.status_code != 200:
130
+ raise Exception(f"Failed to fetch branch data: {response.status_code} - {response.text}")
131
+
132
+ try:
133
+ return GitHubBranch(**response.json())
134
+ except ValidationError as e:
135
+ raise Exception(f"Data validation error: {e}")
136
+
137
+ def list_directory(self, owner, repo, branch="main"):
138
+ """
139
+ List directories in the root of a GitHub repository.
140
+
141
+ :param owner: GitHub username or organization name.
142
+ :param repo: Repository name.
143
+ :param branch: Branch name (default: 'main').
144
+ :return: List of directories in the repository.
145
+ """
146
+ if self.offline_mode:
147
+ raise Exception("Cannot list directories in offline mode")
148
+
149
+ url = f"https://api.github.com/repos/{owner}/{repo}/contents/?ref={branch}"
150
+ response = requests.get(url)
151
+ response.raise_for_status() # Raise an error for bad responses
152
+ contents = response.json()
153
+ directories = [item["name"] for item in contents if item["type"] == "dir"]
154
+ return directories
155
+
156
+ def list_files(self, owner, repo, directory, branch="main"):
157
+ """
158
+ List files in a specific directory of a GitHub repository.
159
+
160
+ :param owner: GitHub username or organization name.
161
+ :param repo: Repository name.
162
+ :param directory: Target directory path within the repo.
163
+ :param branch: Branch name (default: 'main').
164
+ :return: List of files in the specified directory.
165
+ """
166
+ if self.offline_mode:
167
+ raise Exception("Cannot list files in offline mode")
168
+
169
+ url = f"https://api.github.com/repos/{owner}/{repo}/contents/{directory}?ref={branch}"
170
+ response = requests.get(url)
171
+ response.raise_for_status() # Raise an error for bad responses
172
+ contents = response.json()
173
+ files = [item["name"] for item in contents if item["type"] == "file"]
174
+ return files
175
+
176
+ def clone_repository(self, owner: str, repo: str, branch: Optional[str] = None, local_path: str | None = None, shallow: bool = True):
177
+ """
178
+ Clone a GitHub repository to a target directory.
179
+ :param owner: Repository owner
180
+ :param repo: Repository name
181
+ :param target_dir: The directory where the repository should be cloned.
182
+ :param branch: (Optional) The branch to clone. Clones the default branch if None.
183
+ :param shallow: (Optional) If True, performs a shallow clone with --depth 1. Default is True.
184
+ """
185
+ if self.offline_mode:
186
+ raise Exception("Cannot clone repository in offline mode")
187
+
188
+ repo_url = f"https://github.com/{owner}/{repo}.git"
189
+ destination = local_path if local_path else f"{self.repo_dir}/{repo}"
190
+
191
+ command = ["git", "clone", repo_url, destination]
192
+ if shallow:
193
+ command.extend(["--depth", "1"])
194
+ if branch:
195
+ command.extend(["--branch", branch])
196
+ with redirect_stdout_to_log():
197
+ try:
198
+ if not Path(destination).exists():
199
+ subprocess.run(command, check=True)
200
+ _LOGGER.debug(f"Repository cloned successfully into {destination}")
201
+ else:
202
+ current_work_dir = os.getcwd()
203
+ os.chdir(f"{destination}")
204
+
205
+ # Clean up any conflicted state first
206
+ try:
207
+ subprocess.run(["git", "reset", "--hard"], capture_output=True, check=False)
208
+ subprocess.run(["git", "clean", "-fd"], capture_output=True, check=False)
209
+ except Exception:
210
+ pass
211
+
212
+ # Check if the requested branch exists locally
213
+ try:
214
+ result = subprocess.run(
215
+ ["git", "rev-parse", "--verify", f"refs/heads/{branch}"],
216
+ capture_output=True,
217
+ check=False
218
+ )
219
+ branch_exists_locally = result.returncode == 0
220
+ except Exception:
221
+ branch_exists_locally = False
222
+
223
+ if not branch_exists_locally and branch:
224
+ # If branch doesn't exist locally, we need to fetch it
225
+ # For shallow repos, we need to unshallow first or fetch the specific branch
226
+ try:
227
+ # Try to fetch the specific branch
228
+ subprocess.run(["git", "fetch", "origin", f"{branch}:{branch}"], check=True)
229
+ _LOGGER.debug(f"Fetched new branch {branch}")
230
+ except subprocess.CalledProcessError:
231
+ # If that fails, unshallow and try again
232
+ subprocess.run(["git", "fetch", "--unshallow"], check=True)
233
+ subprocess.run(["git", "fetch", "origin", f"{branch}:{branch}"], check=True)
234
+ _LOGGER.debug(f"Unshallowed repo and fetched branch {branch}")
235
+
236
+ # Switch to the requested branch if specified
237
+ if branch:
238
+ subprocess.run(["git", "checkout", branch], check=True)
239
+ _LOGGER.debug(f"Switched to branch {branch}")
240
+
241
+ # For shallow repos that switched branches, just ensure we have latest
242
+ # to avoid merge conflicts from different commit histories
243
+ if branch and not branch_exists_locally:
244
+ # We already fetched the branch, no need for additional reset
245
+ _LOGGER.debug(f"Switched to newly fetched branch {branch}")
246
+ else:
247
+ # Pull latest changes for normal updates
248
+ try:
249
+ subprocess.run(["git", "pull"], check=True)
250
+ except subprocess.CalledProcessError:
251
+ # If pull fails, try to fetch and reset
252
+ subprocess.run(["git", "fetch"], check=True)
253
+ # Check if remote tracking branch exists
254
+ try:
255
+ subprocess.run(["git", "rev-parse", f"origin/{branch}"], capture_output=True, check=True)
256
+ subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True)
257
+ _LOGGER.debug(f"Reset to origin/{branch} after pull failure")
258
+ except subprocess.CalledProcessError:
259
+ # Remote tracking branch doesn't exist, just continue
260
+ _LOGGER.debug(f"No remote tracking branch for {branch}, continuing")
261
+ os.chdir(current_work_dir)
262
+
263
+ except Exception as e:
264
+ raise Exception(f"Failed to clone repository: {e}")
265
+
266
+ def get_github_version_with_api(self, owner: str, repo: str, branch: str = "main"):
267
+ """Fetch the latest commit version (or any other versioning scheme) from GitHub."""
268
+ if self.offline_mode:
269
+ raise Exception("Cannot get GitHub version in offline mode")
270
+ details = self.fetch_branch_details(owner, repo, branch)
271
+ return details.commit.get("sha")
272
+
273
+ def get_github_version(self, owner: str, repo: str, branch: str = "main"):
274
+ """Fetch the latest commit version (or any other versioning scheme) from GitHub. with command git fetch"""
275
+ if self.offline_mode:
276
+ _LOGGER.debug("Cannot get GitHub version in offline mode")
277
+ return None
278
+
279
+ repo_url = f"https://github.com/{owner}/{repo}.git"
280
+ command = ["git", "ls-remote", repo_url, f"{self.repo_dir}/{repo}"]
281
+ if branch:
282
+ command.extend([branch])
283
+
284
+ # with redirect_stdout_to_log():
285
+ output = None
286
+ try:
287
+ result = subprocess.run(command, capture_output=True, text=True, check=True)
288
+ # Parse the output to get the commit hash
289
+ output = result.stdout.strip()
290
+ _LOGGER.debug(f"Repository fetch successfully from {self.repo_dir}/{repo}")
291
+ except Exception as e:
292
+ _LOGGER.debug("error in with git fetch " + repr(e))
293
+ if output is not None:
294
+ commit_hash = output.split()[0]
295
+ return commit_hash
296
+ return None
297
+
298
+ # return git_hash
299
+
300
+ def get_local_repo_version(self, repo_path: str, branch: Optional[str] = "main"):
301
+ """Check the version of the local repository by fetching the latest commit hash."""
302
+ # repo_path = os.path.join(self.repo_dir, repo)
303
+ if os.path.exists(repo_path):
304
+ # print("EXIST")
305
+ command = ["git", "-C", repo_path]
306
+ if branch:
307
+ command.extend(["switch", branch])
308
+ # Ensure we are on the correct branch
309
+ with redirect_stdout_to_log():
310
+ subprocess.run(
311
+ command,
312
+ stdout=subprocess.PIPE, # Capture stdout
313
+ stderr=subprocess.PIPE, # Capture stderr
314
+ text=True,
315
+ ) # Decode output as text
316
+ # Get the latest commit hash (SHA) from the local repository
317
+ commit_hash = subprocess.check_output(
318
+ ["git", "-C", repo_path, "rev-parse", "HEAD"], stderr=subprocess.PIPE, text=True
319
+ ).strip()
320
+ return commit_hash
321
+ return None
322
+
323
+
324
+ if __name__ == "__main__":
325
+ fetcher = RepoFetcher()
326
+
327
+ # Fetch repositories for a user
328
+ # repos = fetcher.fetch_repositories("ESPRI-Mod")
329
+ # for repo in repos:
330
+ # print(repo)
331
+
332
+ # Fetch a specific repository's details
333
+ # repo_details = fetcher.fetch_repository_details("ESPRI-Mod", "mip-cmor-tables")
334
+ # "print(repo_details)
335
+ # branch_details = fetcher.fetch_branch_details("ESPRI-Mod", "mip-cmor-tables", "uni_proj_ld")
336
+ # print(branch_details)
337
+
338
+ fetcher.clone_repository("ESPRI-Mod", "mip-cmor-tables", branch="uni_proj_ld")
339
+
340
+ # a =fetcher.get_github_version("ESPRI-Mod", "mip-cmor-tables", "uni_proj_ld")
341
+ # print(a)
342
+ # a = fetcher.get_local_repo_version("mip-cmor-tables","uni_proj_ld")
343
+ # print(a)
344
+
345
+ fetcher.clone_repository("ESPRI-Mod", "CMIP6Plus_CVs", branch="uni_proj_ld")
@@ -0,0 +1,41 @@
1
+ # from esgvoc.core.service.config_register import ConfigManager
2
+ # from esgvoc.core.service.settings import ServiceSettings
3
+ # from esgvoc.core.service.state import StateService
4
+ #
5
+ # config_manager = ConfigManager()
6
+ # active_setting = config_manager.get_active_config()
7
+ # active_setting["base_dir"] = str(config_manager.config_dir / config_manager.get_active_config_name())
8
+ # service_settings = ServiceSettings.from_config(active_setting)
9
+ # state_service = StateService(service_settings)
10
+
11
+
12
+ from esgvoc.core.service.configuration.config_manager import ConfigManager
13
+ from esgvoc.core.service.configuration.setting import ServiceSettings
14
+ from esgvoc.core.service.state import StateService
15
+
16
+ config_manager : ConfigManager | None = None
17
+ current_state : StateService | None = None
18
+
19
+ def get_config_manager():
20
+ global config_manager
21
+ if config_manager is None:
22
+
23
+ config_manager = ConfigManager(ServiceSettings, app_name="esgvoc", app_author="ipsl", default_settings=ServiceSettings._get_default_settings())
24
+ active_config_name= config_manager.get_active_config_name()
25
+ config_manager.data_config_dir = config_manager.data_dir / active_config_name
26
+ config_manager.data_config_dir.mkdir(parents=True, exist_ok=True)
27
+
28
+ return config_manager
29
+
30
+
31
+ def get_state():
32
+ global current_state
33
+ if config_manager is not None:
34
+ service_settings = config_manager.get_active_config()
35
+ current_state = StateService(service_settings)
36
+ return current_state
37
+
38
+ # Singleton Access Function
39
+ config_manager = get_config_manager()
40
+ current_state = get_state()
41
+
@@ -0,0 +1,196 @@
1
+ import toml
2
+ import logging
3
+ from pathlib import Path
4
+ from platformdirs import PlatformDirs
5
+ from typing import Type, TypeVar, Generic, Protocol
6
+
7
+ # Setup logging
8
+ # Use WARNING level to see important messages (errors, warnings) but not debug/info spam
9
+ logging.basicConfig(
10
+ level=logging.WARNING,
11
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
12
+ )
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Explicitly set data_merger logger to WARNING since something else seems to change it to ERROR
16
+ logging.getLogger("esgvoc.core.service.data_merger").setLevel(logging.WARNING)
17
+
18
+ # Define a generic type for configuration
19
+ T = TypeVar("T", bound="ConfigSchema")
20
+
21
+
22
+ class ConfigSchema(Protocol):
23
+ """Protocol for application-specific configuration classes."""
24
+
25
+ @classmethod
26
+ def load_from_file(cls, file_path: str): ...
27
+
28
+ def save_to_file(self, file_path: str): ...
29
+
30
+
31
+ class ConfigManager(Generic[T]):
32
+ def __init__(self, config_cls: Type[T], app_name: str, app_author: str, default_settings: dict | None = None):
33
+ """
34
+ Initialize the configuration manager.
35
+ - config_cls: A class that implements `ConfigSchema` (e.g., ServiceSettings).
36
+ - app_name: Name of the application (used for directory paths).
37
+ - app_author: Name of the author/organization (used for directory paths).
38
+ """
39
+ self.config_cls = config_cls
40
+ self.dirs = PlatformDirs(app_name, app_author)
41
+
42
+ # Define standard paths
43
+ self.config_dir = Path(self.dirs.user_config_path).expanduser().resolve()
44
+ self.data_dir = Path(self.dirs.user_data_path).expanduser().resolve()
45
+ self.data_config_dir = None # depends on loaded settings
46
+
47
+ self.cache_dir = Path(self.dirs.user_cache_path).expanduser().resolve()
48
+
49
+ self.config_dir.mkdir(parents=True, exist_ok=True)
50
+ self.data_dir.mkdir(parents=True, exist_ok=True)
51
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ self.registry_path = self.config_dir / "config_registry.toml"
54
+ self.default_config_path = self.config_dir / "default_setting.toml"
55
+ self.default_settings = default_settings
56
+ self._init_registry()
57
+
58
+ def _init_registry(self):
59
+ """Initialize the registry file if it doesn't exist."""
60
+ if not self.registry_path.exists():
61
+ logger.info("Initializing configuration registry...")
62
+ registry = {"configs": {"default": str(self.default_config_path)}, "active": "default"}
63
+ self._save_toml(self.registry_path, registry)
64
+ # Ensure the default settings file exists and save it if necessary
65
+ if not self.default_config_path.exists():
66
+ if self.default_settings:
67
+ logger.info("Saving default settings...")
68
+ self._save_toml(self.default_config_path, self.default_settings)
69
+ else:
70
+ logger.warning("No default settings provided.")
71
+
72
+ def _load_toml(self, path: Path) -> dict:
73
+ """Load TOML data from a file."""
74
+ if not path.exists():
75
+ logger.error(f"Configuration file not found: {path}")
76
+ raise FileNotFoundError(f"Configuration file not found: {path}")
77
+ with open(path, "r") as f:
78
+ return toml.load(f)
79
+
80
+ def _save_toml(self, path: Path, data: dict) -> None:
81
+ """Save TOML data to a file."""
82
+ with open(path, "w") as f:
83
+ toml.dump(data, f)
84
+
85
+ def _get_active_config_path(self) -> Path:
86
+ """Retrieve the path of the active configuration file."""
87
+ registry = self._load_toml(self.registry_path)
88
+ active_config_name = registry["active"]
89
+ return Path(registry["configs"][active_config_name])
90
+
91
+ def get_config(self, config_name: str) -> T:
92
+ """Load the configuration as an instance of the given config schema."""
93
+ registry = self._load_toml(self.registry_path)
94
+ if config_name not in registry["configs"]:
95
+ logger.error(f"Config '{config_name}' not found in registry.")
96
+ raise ValueError(f"Config '{config_name}' not found in registry.")
97
+ config_path = registry["configs"][config_name]
98
+ return self.config_cls.load_from_file(str(config_path))
99
+
100
+ def get_active_config(self) -> T:
101
+ """Load the active configuration as an instance of the given config schema."""
102
+ active_config_path = self._get_active_config_path()
103
+ active_config_name = self.get_active_config_name()
104
+
105
+ settings = self.config_cls.load_from_file(str(active_config_path))
106
+ # Set the config name if the settings support it (duck typing)
107
+ if hasattr(settings, 'set_config_name'):
108
+ settings.set_config_name(active_config_name)
109
+ return settings
110
+
111
+ def get_active_config_name(self) -> str:
112
+ """Retrieve the config name from the registry"""
113
+ registry = self._load_toml(self.registry_path)
114
+ return registry["active"]
115
+
116
+ def save_config(self, config_data: dict, name: str | None = None) -> None:
117
+ """Save the modified configuration to the corresponding file and update the registry."""
118
+
119
+ if name:
120
+ # If a name is provided, save the configuration with that name
121
+ config_path = self.config_dir / f"{name}.toml"
122
+ self._save_toml(config_path, config_data)
123
+
124
+ # Update the registry with the new config name
125
+ registry = self._load_toml(self.registry_path)
126
+ registry["configs"][name] = str(config_path)
127
+ registry["active"] = name
128
+ self._save_toml(self.registry_path, registry)
129
+
130
+ logger.info(f"Saved configuration to {config_path} and updated registry.")
131
+ else:
132
+ # If no name is provided, give the user a default name, like "user_config"
133
+ default_name = "user_config"
134
+ config_path = self.config_dir / f"{default_name}.toml"
135
+
136
+ # Check if the user_config already exists, if so, warn them
137
+ if config_path.exists():
138
+ logger.warning(f"{default_name}.toml already exists. Overwriting with the new config.")
139
+
140
+ # Save the configuration with the default name
141
+ self._save_toml(config_path, config_data)
142
+
143
+ # Update the registry with the new config name
144
+ registry = self._load_toml(self.registry_path)
145
+ registry["configs"][default_name] = str(config_path)
146
+ registry["active"] = default_name
147
+ self._save_toml(self.registry_path, registry)
148
+
149
+ logger.info(f"Saved new configuration to {config_path} and updated registry.")
150
+
151
+ def save_active_config(self, config: T):
152
+ """Save the current configuration to the active file."""
153
+ active_config_path = self._get_active_config_path()
154
+ config.save_to_file(str(active_config_path))
155
+
156
+ def switch_config(self, config_name: str):
157
+ """Switch to a different configuration."""
158
+ registry = self._load_toml(self.registry_path)
159
+ if config_name not in registry["configs"]:
160
+ logger.error(f"Config '{config_name}' not found in registry.")
161
+ raise ValueError(f"Config '{config_name}' not found in registry.")
162
+ registry["active"] = config_name
163
+
164
+ self._save_toml(self.registry_path, registry)
165
+ logger.info(f"Switched to configuration: {config_name}")
166
+
167
+ def list_configs(self) -> dict:
168
+ """Return a list of available configurations."""
169
+ return self._load_toml(self.registry_path)["configs"]
170
+
171
+ def add_config(self, config_name: str, config_data: dict):
172
+ """Add a new configuration."""
173
+ registry = self._load_toml(self.registry_path)
174
+ if config_name in registry["configs"]:
175
+ raise ValueError(f"Config '{config_name}' already exists.")
176
+ config_path = self.config_dir / f"{config_name}.toml"
177
+ self._save_toml(config_path, config_data)
178
+ registry["configs"][config_name] = str(config_path)
179
+ self._save_toml(self.registry_path, registry)
180
+
181
+ def remove_config(self, config_name: str):
182
+ """Remove a configuration."""
183
+ registry = self._load_toml(self.registry_path)
184
+ if config_name == "default":
185
+ raise ValueError("Cannot remove the default configuration.")
186
+ if config_name not in registry["configs"]:
187
+ raise ValueError(f"Config '{config_name}' not found.")
188
+ del registry["configs"][config_name]
189
+ config_path = self.config_dir / f"{config_name}.toml"
190
+ config_path.unlink()
191
+
192
+ self._save_toml(self.registry_path, registry)
193
+ logger.info(f"Removed configuration: {config_name}")
194
+ if registry["active"] not in registry["configs"]:
195
+ self.switch_config("default")
196
+ logger.info("active configuration doesnot exist anymore : Switch to default configuration")