esgvoc 0.4.0__py3-none-any.whl → 1.0.1__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.

Potentially problematic release.


This version of esgvoc might be problematic. Click here for more details.

Files changed (74) hide show
  1. esgvoc/__init__.py +1 -1
  2. esgvoc/api/data_descriptors/__init__.py +52 -28
  3. esgvoc/api/data_descriptors/activity.py +3 -3
  4. esgvoc/api/data_descriptors/area_label.py +16 -1
  5. esgvoc/api/data_descriptors/branded_suffix.py +20 -0
  6. esgvoc/api/data_descriptors/branded_variable.py +12 -0
  7. esgvoc/api/data_descriptors/consortium.py +14 -13
  8. esgvoc/api/data_descriptors/contact.py +5 -0
  9. esgvoc/api/data_descriptors/conventions.py +6 -0
  10. esgvoc/api/data_descriptors/creation_date.py +5 -0
  11. esgvoc/api/data_descriptors/data_descriptor.py +14 -9
  12. esgvoc/api/data_descriptors/data_specs_version.py +5 -0
  13. esgvoc/api/data_descriptors/date.py +1 -1
  14. esgvoc/api/data_descriptors/directory_date.py +1 -1
  15. esgvoc/api/data_descriptors/experiment.py +13 -11
  16. esgvoc/api/data_descriptors/forcing_index.py +1 -1
  17. esgvoc/api/data_descriptors/frequency.py +3 -3
  18. esgvoc/api/data_descriptors/further_info_url.py +5 -0
  19. esgvoc/api/data_descriptors/grid_label.py +2 -2
  20. esgvoc/api/data_descriptors/horizontal_label.py +15 -1
  21. esgvoc/api/data_descriptors/initialisation_index.py +1 -1
  22. esgvoc/api/data_descriptors/institution.py +8 -5
  23. esgvoc/api/data_descriptors/known_branded_variable.py +23 -0
  24. esgvoc/api/data_descriptors/license.py +3 -3
  25. esgvoc/api/data_descriptors/member_id.py +9 -0
  26. esgvoc/api/data_descriptors/mip_era.py +1 -1
  27. esgvoc/api/data_descriptors/model_component.py +1 -1
  28. esgvoc/api/data_descriptors/obs_type.py +5 -0
  29. esgvoc/api/data_descriptors/organisation.py +1 -1
  30. esgvoc/api/data_descriptors/physic_index.py +1 -1
  31. esgvoc/api/data_descriptors/product.py +2 -2
  32. esgvoc/api/data_descriptors/publication_status.py +5 -0
  33. esgvoc/api/data_descriptors/realisation_index.py +1 -1
  34. esgvoc/api/data_descriptors/realm.py +1 -1
  35. esgvoc/api/data_descriptors/region.py +5 -0
  36. esgvoc/api/data_descriptors/resolution.py +3 -3
  37. esgvoc/api/data_descriptors/source.py +9 -5
  38. esgvoc/api/data_descriptors/source_type.py +1 -1
  39. esgvoc/api/data_descriptors/table.py +3 -2
  40. esgvoc/api/data_descriptors/temporal_label.py +15 -1
  41. esgvoc/api/data_descriptors/time_range.py +4 -3
  42. esgvoc/api/data_descriptors/title.py +5 -0
  43. esgvoc/api/data_descriptors/tracking_id.py +5 -0
  44. esgvoc/api/data_descriptors/variable.py +25 -12
  45. esgvoc/api/data_descriptors/variant_label.py +3 -3
  46. esgvoc/api/data_descriptors/vertical_label.py +14 -0
  47. esgvoc/api/project_specs.py +117 -2
  48. esgvoc/api/projects.py +328 -287
  49. esgvoc/api/search.py +30 -3
  50. esgvoc/api/universe.py +42 -27
  51. esgvoc/apps/drs/generator.py +87 -74
  52. esgvoc/apps/jsg/cmip6_template.json +74 -0
  53. esgvoc/apps/jsg/json_schema_generator.py +194 -0
  54. esgvoc/cli/config.py +500 -0
  55. esgvoc/cli/find.py +138 -0
  56. esgvoc/cli/get.py +43 -38
  57. esgvoc/cli/main.py +10 -3
  58. esgvoc/cli/status.py +27 -18
  59. esgvoc/cli/valid.py +10 -15
  60. esgvoc/core/db/models/project.py +11 -11
  61. esgvoc/core/db/models/universe.py +3 -3
  62. esgvoc/core/db/project_ingestion.py +40 -40
  63. esgvoc/core/db/universe_ingestion.py +36 -33
  64. esgvoc/core/logging_handler.py +24 -2
  65. esgvoc/core/repo_fetcher.py +61 -59
  66. esgvoc/core/service/data_merger.py +47 -34
  67. esgvoc/core/service/state.py +107 -83
  68. {esgvoc-0.4.0.dist-info → esgvoc-1.0.1.dist-info}/METADATA +5 -20
  69. esgvoc-1.0.1.dist-info/RECORD +95 -0
  70. esgvoc/core/logging.conf +0 -21
  71. esgvoc-0.4.0.dist-info/RECORD +0 -80
  72. {esgvoc-0.4.0.dist-info → esgvoc-1.0.1.dist-info}/WHEEL +0 -0
  73. {esgvoc-0.4.0.dist-info → esgvoc-1.0.1.dist-info}/entry_points.txt +0 -0
  74. {esgvoc-0.4.0.dist-info → esgvoc-1.0.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,194 @@
1
+ import contextlib
2
+ import json
3
+ from json import JSONEncoder
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ from sqlmodel import Session
8
+
9
+ from esgvoc.api import projects, search
10
+ from esgvoc.api.project_specs import (
11
+ GlobalAttributeSpecBase,
12
+ GlobalAttributeSpecSpecific,
13
+ GlobalAttributeVisitor,
14
+ )
15
+ from esgvoc.core.constants import DRS_SPECS_JSON_KEY, PATTERN_JSON_KEY
16
+ from esgvoc.core.db.models.project import PCollection, TermKind
17
+ from esgvoc.core.exceptions import EsgvocNotFoundError, EsgvocNotImplementedError
18
+
19
+ KEY_SEPARATOR = ':'
20
+ JSON_SCHEMA_TEMPLATE_DIR_PATH = Path(__file__).parent
21
+ JSON_SCHEMA_TEMPLATE_FILE_NAME_TEMPLATE = '{project_id}_template.json'
22
+ JSON_INDENTATION = 2
23
+
24
+
25
+ def _process_plain(collection: PCollection, selected_field: str) -> set[str]:
26
+ result: set[str] = set()
27
+ for term in collection.terms:
28
+ if selected_field in term.specs:
29
+ value = term.specs[selected_field]
30
+ result.add(value)
31
+ else:
32
+ raise EsgvocNotFoundError(f'missing key {selected_field} for term {term.id} in ' +
33
+ f'collection {collection.id}')
34
+ return result
35
+
36
+
37
+ def _process_composite(collection: PCollection, universe_session: Session,
38
+ project_session: Session) -> str:
39
+ result = ""
40
+ for term in collection.terms:
41
+ _, parts = projects._get_composite_term_separator_parts(term)
42
+ for part in parts:
43
+ resolved_term = projects._resolve_term(part, universe_session, project_session)
44
+ if resolved_term.kind == TermKind.PATTERN:
45
+ result += resolved_term.specs[PATTERN_JSON_KEY]
46
+ else:
47
+ raise EsgvocNotImplementedError(f'{term.kind} term is not supported yet')
48
+ # Patterns terms are meant to be validated individually.
49
+ # So their regex are defined as a whole (begins by a ^, ends by a $).
50
+ # As the pattern is a concatenation of plain or regex, multiple ^ and $ can exist.
51
+ # The later, must be removed.
52
+ result = result.replace('^', '').replace('$', '')
53
+ result = f'^{result}$'
54
+ return result
55
+
56
+
57
+ def _process_pattern(collection: PCollection) -> str:
58
+ # The generation of the value of the field pattern for the collections with more than one term
59
+ # is not specified yet.
60
+ if len(collection.terms) == 1:
61
+ term = collection.terms[0]
62
+ return term.specs[PATTERN_JSON_KEY]
63
+ else:
64
+ msg = f"unsupported collection of term pattern with more than one term for '{collection.id}'"
65
+ raise EsgvocNotImplementedError(msg)
66
+
67
+
68
+ def _generate_attribute_key(project_id: str, attribute_name) -> str:
69
+ return f'{project_id}{KEY_SEPARATOR}{attribute_name}'
70
+
71
+
72
+ class JsonPropertiesVisitor(GlobalAttributeVisitor, contextlib.AbstractContextManager):
73
+ def __init__(self, project_id: str) -> None:
74
+ self.project_id = project_id
75
+ # Project session can't be None here.
76
+ self.universe_session: Session = search.get_universe_session()
77
+ self.project_session: Session = projects._get_project_session_with_exception(project_id)
78
+ self.collections: dict[str, PCollection] = dict()
79
+ for collection in projects._get_all_collections_in_project(self.project_session):
80
+ self.collections[collection.id] = collection
81
+
82
+ def __exit__(self, exception_type, exception_value, exception_traceback):
83
+ self.project_session.close()
84
+ self.universe_session.close()
85
+ if exception_type is not None:
86
+ raise exception_value
87
+ return True
88
+
89
+ def _generate_attribute_property(self, attribute_name: str, source_collection: str,
90
+ selected_field: str) -> tuple[str, str | set[str]]:
91
+ property_value: str | set[str]
92
+ property_key: str
93
+ if source_collection not in self.collections:
94
+ raise EsgvocNotFoundError(f"collection '{source_collection}' referenced by attribute " +
95
+ f"{attribute_name} is not found")
96
+ collection = self.collections[source_collection]
97
+ match collection.term_kind:
98
+ case TermKind.PLAIN:
99
+ property_value = _process_plain(collection=collection,
100
+ selected_field=selected_field)
101
+ property_key = 'enum'
102
+ case TermKind.COMPOSITE:
103
+ property_value = _process_composite(collection=collection,
104
+ universe_session=self.universe_session,
105
+ project_session=self.project_session)
106
+ property_key = 'pattern'
107
+ case TermKind.PATTERN:
108
+ property_value = _process_pattern(collection)
109
+ property_key = 'pattern'
110
+ case _:
111
+ msg = f"unsupported term kind '{collection.term_kind}' " + \
112
+ f"for global attribute {attribute_name}"
113
+ raise EsgvocNotImplementedError(msg)
114
+ return property_key, property_value
115
+
116
+ def visit_base_attribute(self, attribute_name: str, attribute: GlobalAttributeSpecBase) \
117
+ -> tuple[str, dict[str, str | set[str]]]:
118
+ attribute_key = _generate_attribute_key(self.project_id, attribute_name)
119
+ attribute_properties: dict[str, str | set[str]] = dict()
120
+ attribute_properties['type'] = attribute.value_type.value
121
+ property_key, property_value = self._generate_attribute_property(attribute_name,
122
+ attribute.source_collection,
123
+ DRS_SPECS_JSON_KEY)
124
+ attribute_properties[property_key] = property_value
125
+ return attribute_key, attribute_properties
126
+
127
+ def visit_specific_attribute(self, attribute_name: str, attribute: GlobalAttributeSpecSpecific) \
128
+ -> tuple[str, dict[str, str | set[str]]]:
129
+ attribute_key = _generate_attribute_key(self.project_id, attribute_name)
130
+ attribute_properties: dict[str, str | set[str]] = dict()
131
+ attribute_properties['type'] = attribute.value_type.value
132
+ property_key, property_value = self._generate_attribute_property(attribute_name,
133
+ attribute.source_collection,
134
+ attribute.specific_key)
135
+ attribute_properties[property_key] = property_value
136
+ return attribute_key, attribute_properties
137
+
138
+
139
+ def _inject_global_attributes(json_root: dict, project_id: str, attribute_names: Iterable[str]) -> None:
140
+ attribute_properties = list()
141
+ for attribute_name in attribute_names:
142
+ attribute_key = _generate_attribute_key(project_id, attribute_name)
143
+ attribute_properties.append({"required": [attribute_key]})
144
+ json_root['definitions']['require_any']['anyOf'] = attribute_properties
145
+
146
+
147
+ def _inject_properties(json_root: dict, properties: list[tuple]) -> None:
148
+ for property in properties:
149
+ json_root['definitions']['fields']['properties'][property[0]] = property[1]
150
+
151
+
152
+ class SetEncoder(JSONEncoder):
153
+ def default(self, o):
154
+ if isinstance(o, set):
155
+ return list(o)
156
+ else:
157
+ return super().default(self, o)
158
+
159
+
160
+ def generate_json_schema(project_id: str) -> str:
161
+ """
162
+ Generate json schema for the given project.
163
+
164
+ :param project_id: The id of the given project.
165
+ :type project_id: str
166
+ :returns: The content of a json schema
167
+ :rtype: str
168
+ :raises EsgvocNotFoundError: On missing information
169
+ :raises EsgvocNotImplementedError: On unexpected operations
170
+ """
171
+ file_name = JSON_SCHEMA_TEMPLATE_FILE_NAME_TEMPLATE.format(project_id=project_id)
172
+ template_file_path = JSON_SCHEMA_TEMPLATE_DIR_PATH.joinpath(file_name)
173
+ if template_file_path.exists():
174
+ project_specs = projects.get_project(project_id)
175
+ if project_specs:
176
+ if project_specs.global_attributes_specs:
177
+ with open(file=template_file_path, mode='r') as file, \
178
+ JsonPropertiesVisitor(project_id) as visitor:
179
+ file_content = file.read()
180
+ root = json.loads(file_content)
181
+ properties: list[tuple[str, dict[str, str | set[str]]]] = list()
182
+ for attribute_name, attribute in project_specs.global_attributes_specs.items():
183
+ attribute_key, attribute_properties = attribute.accept(attribute_name, visitor)
184
+ properties.append((attribute_key, attribute_properties))
185
+ _inject_properties(root, properties)
186
+ _inject_global_attributes(root, project_id, project_specs.global_attributes_specs.keys())
187
+ return json.dumps(root, indent=JSON_INDENTATION, cls=SetEncoder)
188
+ else:
189
+ raise EsgvocNotFoundError(f"global attributes for the project '{project_id}' " +
190
+ "are not provided")
191
+ else:
192
+ raise EsgvocNotFoundError(f"specs of project '{project_id}' is not found")
193
+ else:
194
+ raise EsgvocNotFoundError(f"template for project '{project_id}' is not found")
esgvoc/cli/config.py ADDED
@@ -0,0 +1,500 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+
5
+ import toml
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.syntax import Syntax
9
+ from rich.table import Table
10
+
11
+ import esgvoc.core.service as service
12
+ from esgvoc.core.service.configuration.setting import ServiceSettings
13
+
14
+ app = typer.Typer()
15
+ console = Console()
16
+
17
+
18
+ def display(table):
19
+ """
20
+ Function to display a rich table in the console.
21
+
22
+ :param table: The table to be displayed
23
+ """
24
+ console = Console(record=True, width=200)
25
+ console.print(table)
26
+
27
+
28
+ @app.command()
29
+ def list():
30
+ """
31
+ List all available configurations.
32
+
33
+ Displays all available configurations along with the active one.
34
+ """
35
+ config_manager = service.get_config_manager()
36
+ configs = config_manager.list_configs()
37
+ active_config = config_manager.get_active_config_name()
38
+
39
+ table = Table(title="Available Configurations")
40
+ table.add_column("Name", style="cyan")
41
+ table.add_column("Path", style="green")
42
+ table.add_column("Status", style="magenta")
43
+
44
+ for name, path in configs.items():
45
+ status = "🟢 Active" if name == active_config else ""
46
+ table.add_row(name, path, status)
47
+
48
+ display(table)
49
+
50
+
51
+ @app.command()
52
+ def show(
53
+ name: Optional[str] = typer.Argument(
54
+ None, help="Name of the configuration to show. If not provided, shows the active configuration."
55
+ ),
56
+ ):
57
+ """
58
+ Show the content of a specific configuration.
59
+
60
+ Args:
61
+ name: Name of the configuration to show. Shows the active configuration if not specified.
62
+ """
63
+ config_manager = service.get_config_manager()
64
+ if name is None:
65
+ name = config_manager.get_active_config_name()
66
+ console.print(f"Showing active configuration: [cyan]{name}[/cyan]")
67
+
68
+ configs = config_manager.list_configs()
69
+ if name not in configs:
70
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
71
+ raise typer.Exit(1)
72
+
73
+ config_path = configs[name]
74
+ try:
75
+ with open(config_path, "r") as f:
76
+ content = f.read()
77
+
78
+ syntax = Syntax(content, "toml", theme="monokai", line_numbers=True)
79
+ console.print(syntax)
80
+ except Exception as e:
81
+ console.print(f"[red]Error reading configuration file: {str(e)}[/red]")
82
+ raise typer.Exit(1)
83
+
84
+
85
+ @app.command()
86
+ def switch(name: str = typer.Argument(..., help="Name of the configuration to switch to.")):
87
+ """
88
+ Switch to a different configuration.
89
+
90
+ Args:
91
+ name: Name of the configuration to switch to.
92
+ """
93
+ config_manager = service.get_config_manager()
94
+ configs = config_manager.list_configs()
95
+
96
+ if name not in configs:
97
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
98
+ raise typer.Exit(1)
99
+
100
+ try:
101
+ config_manager.switch_config(name)
102
+ console.print(f"[green]Successfully switched to configuration: [cyan]{name}[/cyan][/green]")
103
+
104
+ # Reset the state to use the new configuration
105
+ service.current_state = service.get_state()
106
+ except Exception as e:
107
+ console.print(f"[red]Error switching configuration: {str(e)}[/red]")
108
+ raise typer.Exit(1)
109
+
110
+
111
+ @app.command()
112
+ def create(
113
+ name: str = typer.Argument(..., help="Name for the new configuration."),
114
+ base: Optional[str] = typer.Option(
115
+ None, "--base", "-b", help="Base the new configuration on an existing one. Uses the default if not specified."
116
+ ),
117
+ switch_to: bool = typer.Option(False, "--switch", "-s", help="Switch to the new configuration after creating it."),
118
+ ):
119
+ """
120
+ Create a new configuration.
121
+
122
+ Args:
123
+ name: Name for the new configuration.
124
+ base: Base the new configuration on an existing one. Uses the default if not specified.
125
+ switch_to: Switch to the new configuration after creating it.
126
+ """
127
+ config_manager = service.get_config_manager()
128
+ configs = config_manager.list_configs()
129
+
130
+ if name in configs:
131
+ console.print(f"[red]Error: Configuration '{name}' already exists.[/red]")
132
+ raise typer.Exit(1)
133
+
134
+ if base and base not in configs:
135
+ console.print(f"[red]Error: Base configuration '{base}' not found.[/red]")
136
+ raise typer.Exit(1)
137
+
138
+ try:
139
+ if base:
140
+ # Load the base configuration
141
+ base_config = config_manager.get_config(base)
142
+ config_data = base_config.dump()
143
+ else:
144
+ # Use default settings
145
+ config_data = ServiceSettings.DEFAULT_SETTINGS
146
+
147
+ # Add the new configuration
148
+ config_manager.add_config(name, config_data)
149
+ console.print(f"[green]Successfully created configuration: [cyan]{name}[/cyan][/green]")
150
+
151
+ if switch_to:
152
+ config_manager.switch_config(name)
153
+ console.print(f"[green]Switched to configuration: [cyan]{name}[/cyan][/green]")
154
+ # Reset the state to use the new configuration
155
+ service.current_state = service.get_state()
156
+
157
+ except Exception as e:
158
+ console.print(f"[red]Error creating configuration: {str(e)}[/red]")
159
+ raise typer.Exit(1)
160
+
161
+
162
+ @app.command()
163
+ def remove(name: str = typer.Argument(..., help="Name of the configuration to remove.")):
164
+ """
165
+ Remove a configuration.
166
+
167
+ Args:
168
+ name: Name of the configuration to remove.
169
+ """
170
+ config_manager = service.get_config_manager()
171
+ configs = config_manager.list_configs()
172
+
173
+ if name not in configs:
174
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
175
+ raise typer.Exit(1)
176
+
177
+ if name == "default":
178
+ console.print("[red]Error: Cannot remove the default configuration.[/red]")
179
+ raise typer.Exit(1)
180
+
181
+ confirm = typer.confirm(f"Are you sure you want to remove configuration '{name}'?")
182
+ if not confirm:
183
+ console.print("Operation cancelled.")
184
+ return
185
+
186
+ try:
187
+ active_config = config_manager.get_active_config_name()
188
+ config_manager.remove_config(name)
189
+ console.print(f"[green]Successfully removed configuration: [cyan]{name}[/cyan][/green]")
190
+
191
+ if active_config == name:
192
+ console.print("[yellow]Active configuration was removed. Switched to default.[/yellow]")
193
+ # Reset the state to use the default configuration
194
+ service.current_state = service.get_state()
195
+ except Exception as e:
196
+ console.print(f"[red]Error removing configuration: {str(e)}[/red]")
197
+ raise typer.Exit(1)
198
+
199
+
200
+ @app.command()
201
+ def edit(
202
+ name: Optional[str] = typer.Argument(
203
+ None, help="Name of the configuration to edit. Edits the active configuration if not specified."
204
+ ),
205
+ editor: Optional[str] = typer.Option(
206
+ None, "--editor", "-e", help="Editor to use. Uses the system default if not specified."
207
+ ),
208
+ ):
209
+ """
210
+ Edit a configuration using the system's default editor or a specified one.
211
+
212
+ Args:
213
+ name: Name of the configuration to edit. Edits the active configuration if not specified.
214
+ editor: Editor to use. Uses the system default if not specified.
215
+ """
216
+ config_manager = service.get_config_manager()
217
+ if name is None:
218
+ name = config_manager.get_active_config_name()
219
+ console.print(f"Editing active configuration: [cyan]{name}[/cyan]")
220
+
221
+ configs = config_manager.list_configs()
222
+ if name not in configs:
223
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
224
+ raise typer.Exit(1)
225
+
226
+ config_path = configs[name]
227
+
228
+ editor_cmd = editor or os.environ.get("EDITOR", "vim")
229
+ try:
230
+ # Launch the editor properly by using a list of arguments instead of a string
231
+ import subprocess
232
+
233
+ result = subprocess.run([editor_cmd, str(config_path)], check=True)
234
+ if result.returncode == 0:
235
+ console.print(f"[green]Successfully edited configuration: [cyan]{name}[/cyan][/green]")
236
+
237
+ # Reset the state if we edited the active configuration
238
+ if name == config_manager.get_active_config_name():
239
+ service.current_state = service.get_state()
240
+ else:
241
+ console.print("[yellow]Editor exited with an error.[/yellow]")
242
+ except Exception as e:
243
+ console.print(f"[red]Error launching editor: {str(e)}[/red]")
244
+ raise typer.Exit(1)
245
+
246
+
247
+ @app.command()
248
+ def set(
249
+ changes: List[str] = typer.Argument(
250
+ ...,
251
+ help="Changes in format 'component:key=value', where component is 'universe' or a project name. Multiple can be specified.",
252
+ ),
253
+ config_name: Optional[str] = typer.Option(
254
+ None,
255
+ "--config",
256
+ "-c",
257
+ help="Name of the configuration to modify. Modifies the active configuration if not specified.",
258
+ ),
259
+ ):
260
+ """
261
+ Modify configuration settings using a consistent syntax for universe and projects.
262
+
263
+ Args:
264
+ changes: List of changes in format 'component:key=value'. For example:
265
+ 'universe:branch=main' - Change the universe branch
266
+ 'cmip6:github_repo=https://github.com/new/repo' - Change a project's repository
267
+ config_name: Name of the configuration to modify. Modifies the active configuration if not specified.
268
+
269
+ Examples:
270
+ # Change the universe branch in the active configuration
271
+ esgvoc set 'universe:branch=esgvoc_dev'
272
+
273
+ # Change multiple components at once
274
+ esgvoc set 'universe:branch=esgvoc_dev' 'cmip6:branch=esgvoc_dev'
275
+
276
+ # Change settings in a specific configuration
277
+ esgvoc set 'universe:local_path=repos/prod/universe' --config prod
278
+
279
+ # Change the GitHub repository URL for a project
280
+ esgvoc set 'cmip6:github_repo=https://github.com/WCRP-CMIP/CMIP6_CVs_new'
281
+ """
282
+ config_manager = service.get_config_manager()
283
+ if config_name is None:
284
+ config_name = config_manager.get_active_config_name()
285
+ console.print(f"Modifying active configuration: [cyan]{config_name}[/cyan]")
286
+
287
+ configs = config_manager.list_configs()
288
+ if config_name not in configs:
289
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
290
+ raise typer.Exit(1)
291
+
292
+ try:
293
+ # Load the configuration
294
+ config = config_manager.get_config(config_name)
295
+ modified = False
296
+
297
+ # Process all changes with the same format
298
+ for change in changes:
299
+ try:
300
+ # Format should be component:setting=value (where component is 'universe' or a project name)
301
+ component_part, setting_part = change.split(":", 1)
302
+ setting_key, setting_value = setting_part.split("=", 1)
303
+
304
+ # Handle universe settings
305
+ if component_part == "universe":
306
+ if setting_key == "github_repo":
307
+ config.universe.github_repo = setting_value
308
+ modified = True
309
+ elif setting_key == "branch":
310
+ config.universe.branch = setting_value
311
+ modified = True
312
+ elif setting_key == "local_path":
313
+ config.universe.local_path = setting_value
314
+ modified = True
315
+ elif setting_key == "db_path":
316
+ config.universe.db_path = setting_value
317
+ modified = True
318
+ else:
319
+ console.print(f"[yellow]Warning: Unknown universe setting '{setting_key}'. Skipping.[/yellow]")
320
+ continue
321
+
322
+ console.print(f"[green]Updated universe.{setting_key} = {setting_value}[/green]")
323
+
324
+ # Handle project settings
325
+ elif component_part in config.projects:
326
+ project = config.projects[component_part]
327
+ if setting_key == "github_repo":
328
+ project.github_repo = setting_value
329
+ elif setting_key == "branch":
330
+ project.branch = setting_value
331
+ elif setting_key == "local_path":
332
+ project.local_path = setting_value
333
+ elif setting_key == "db_path":
334
+ project.db_path = setting_value
335
+ else:
336
+ console.print(f"[yellow]Warning: Unknown project setting '{setting_key}'. Skipping.[/yellow]")
337
+ continue
338
+
339
+ modified = True
340
+ console.print(f"[green]Updated {component_part}.{setting_key} = {setting_value}[/green]")
341
+ else:
342
+ console.print(
343
+ f"[yellow]Warning: Component '{component_part}' not found in configuration. Skipping.[/yellow]"
344
+ )
345
+ continue
346
+
347
+ except ValueError:
348
+ console.print(
349
+ f"[yellow]Warning: Invalid change format '{change}'. Should be 'component:key=value'. Skipping.[/yellow]"
350
+ )
351
+
352
+ if modified:
353
+ # Save the modified configuration
354
+ config_manager.save_active_config(config)
355
+ console.print(f"[green]Successfully updated configuration: [cyan]{config_name}[/cyan][/green]")
356
+
357
+ # Reset the state if we modified the active configuration
358
+ if config_name == config_manager.get_active_config_name():
359
+ service.current_state = service.get_state()
360
+ else:
361
+ console.print("[yellow]No changes were made to the configuration.[/yellow]")
362
+
363
+ except Exception as e:
364
+ console.print(f"[red]Error updating configuration: {str(e)}[/red]")
365
+ raise typer.Exit(1)
366
+
367
+
368
+ @app.command()
369
+ def add_project(
370
+ name: Optional[str] = typer.Argument(
371
+ None, help="Name of the configuration to modify. Modifies the active configuration if not specified."
372
+ ),
373
+ project_name: str = typer.Option(..., "--name", "-n", help="Name of the project to add."),
374
+ github_repo: str = typer.Option(..., "--repo", "-r", help="GitHub repository URL for the project."),
375
+ branch: str = typer.Option("main", "--branch", "-b", help="Branch for the project repository."),
376
+ local_path: Optional[str] = typer.Option(None, "--local", "-l", help="Local path for the project repository."),
377
+ db_path: Optional[str] = typer.Option(None, "--db", "-d", help="Database path for the project."),
378
+ ):
379
+ """
380
+ Add a new project to a configuration.
381
+
382
+ Args:
383
+ name: Name of the configuration to modify. Modifies the active configuration if not specified.
384
+ project_name: Name of the project to add.
385
+ github_repo: GitHub repository URL for the project.
386
+ branch: Branch for the project repository.
387
+ local_path: Local path for the project repository.
388
+ db_path: Database path for the project.
389
+ """
390
+ config_manager = service.get_config_manager()
391
+ if name is None:
392
+ name = config_manager.get_active_config_name()
393
+ console.print(f"Modifying active configuration: [cyan]{name}[/cyan]")
394
+
395
+ configs = config_manager.list_configs()
396
+ if name not in configs:
397
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
398
+ raise typer.Exit(1)
399
+
400
+ try:
401
+ # Load the configuration
402
+ config = config_manager.get_config(name)
403
+
404
+ # Check if project already exists
405
+ if project_name in config.projects:
406
+ console.print(f"[red]Error: Project '{project_name}' already exists in configuration '{name}'.[/red]")
407
+ raise typer.Exit(1)
408
+
409
+ # Set default paths if not provided
410
+ if local_path is None:
411
+ local_path = f"repos/{project_name}"
412
+ if db_path is None:
413
+ db_path = f"dbs/{project_name}.sqlite"
414
+
415
+ # Create the project settings
416
+ from esgvoc.core.service.configuration.setting import ProjectSettings
417
+
418
+ project_settings = ProjectSettings(
419
+ project_name=project_name, github_repo=github_repo, branch=branch, local_path=local_path, db_path=db_path
420
+ )
421
+
422
+ # Add to configuration
423
+ config.projects[project_name] = project_settings
424
+
425
+ # Save the configuration
426
+ config_manager.save_active_config(config)
427
+ console.print(
428
+ f"[green]Successfully added project [cyan]{project_name}[/cyan] to configuration [cyan]{name}[/cyan][/green]"
429
+ )
430
+
431
+ # Reset the state if we modified the active configuration
432
+ if name == config_manager.get_active_config_name():
433
+ service.current_state = service.get_state()
434
+
435
+ except Exception as e:
436
+ console.print(f"[red]Error adding project: {str(e)}[/red]")
437
+ raise typer.Exit(1)
438
+
439
+
440
+ @app.command()
441
+ def remove_project(
442
+ name: Optional[str] = typer.Argument(
443
+ None, help="Name of the configuration to modify. Modifies the active configuration if not specified."
444
+ ),
445
+ project_name: str = typer.Argument(..., help="Name of the project to remove."),
446
+ ):
447
+ """
448
+ Remove a project from a configuration.
449
+
450
+ Args:
451
+ name: Name of the configuration to modify. Modifies the active configuration if not specified.
452
+ project_name: Name of the project to remove.
453
+ """
454
+ config_manager = service.get_config_manager()
455
+ if name is None:
456
+ name = config_manager.get_active_config_name()
457
+ console.print(f"Modifying active configuration: [cyan]{name}[/cyan]")
458
+
459
+ configs = config_manager.list_configs()
460
+ if name not in configs:
461
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
462
+ raise typer.Exit(1)
463
+
464
+ try:
465
+ # Load the configuration
466
+ config = config_manager.get_config(name)
467
+
468
+ # Check if project exists
469
+ if project_name not in config.projects:
470
+ console.print(f"[red]Error: Project '{project_name}' not found in configuration '{name}'.[/red]")
471
+ raise typer.Exit(1)
472
+
473
+ # Confirm removal
474
+ confirm = typer.confirm(
475
+ f"Are you sure you want to remove project '{project_name}' from configuration '{name}'?"
476
+ )
477
+ if not confirm:
478
+ console.print("Operation cancelled.")
479
+ return
480
+
481
+ # Remove project
482
+ del config.projects[project_name]
483
+
484
+ # Save the configuration
485
+ config_manager.save_active_config(config)
486
+ console.print(
487
+ f"[green]Successfully removed project [cyan]{project_name}[/cyan] from configuration [cyan]{name}[/cyan][/green]"
488
+ )
489
+
490
+ # Reset the state if we modified the active configuration
491
+ if name == config_manager.get_active_config_name():
492
+ service.current_state = service.get_state()
493
+
494
+ except Exception as e:
495
+ console.print(f"[red]Error removing project: {str(e)}[/red]")
496
+ raise typer.Exit(1)
497
+
498
+
499
+ if __name__ == "__main__":
500
+ app()