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
esgvoc/cli/config.py ADDED
@@ -0,0 +1,1300 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ import toml
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.syntax import Syntax
10
+ from rich.table import Table
11
+
12
+ # Import service module but don't initialize it immediately
13
+ import esgvoc.core.service.configuration.config_manager as config_manager_module
14
+ from esgvoc.core.service.configuration.setting import ServiceSettings
15
+
16
+ def get_service():
17
+ """Get the service module, importing it only when needed."""
18
+ import esgvoc.core.service as service
19
+ return service
20
+
21
+ app = typer.Typer()
22
+ console = Console()
23
+
24
+
25
+ def _get_fresh_config(config_manager, config_name: str):
26
+ """
27
+ Get a fresh configuration, bypassing any potential caching issues.
28
+ """
29
+ # Force reload from file to ensure we have the latest state
30
+ configs = config_manager.list_configs()
31
+ config_path = configs[config_name]
32
+
33
+ # Load directly from file to avoid any caching
34
+ try:
35
+ data = toml.load(config_path)
36
+ projects = {p["project_name"]: ServiceSettings.ProjectSettings(**p) for p in data.pop("projects", [])}
37
+ from esgvoc.core.service.configuration.setting import UniverseSettings
38
+
39
+ return ServiceSettings(universe=UniverseSettings(**data["universe"]), projects=projects)
40
+ except Exception:
41
+ # Fallback to config manager if direct load fails
42
+ return config_manager.get_config(config_name)
43
+
44
+
45
+ def _save_and_reload_config(config_manager, config_name: str, config):
46
+ """
47
+ Save configuration and ensure proper state reload.
48
+ """
49
+ config_manager.save_active_config(config)
50
+
51
+ # Reset the state if we modified the active configuration
52
+ if config_name == config_manager.get_active_config_name():
53
+ service.current_state = service.get_state()
54
+
55
+ # Clear any potential caches in the config manager
56
+ if hasattr(config_manager, "_cached_config"):
57
+ config_manager._cached_config = None
58
+ if hasattr(config_manager, "cache"):
59
+ config_manager.cache.clear()
60
+
61
+ """
62
+ Function to display a rich table in the console.
63
+
64
+ :param table: The table to be displayed
65
+ """
66
+ console = Console(record=True, width=200)
67
+ console.print(table)
68
+
69
+
70
+ def display(table):
71
+ """
72
+ Function to display a rich table in the console.
73
+
74
+ :param table: The table to be displayed
75
+ """
76
+ console = Console(record=True, width=200)
77
+ console.print(table)
78
+
79
+
80
+ @app.command()
81
+ def list():
82
+ """
83
+ List all available configurations.
84
+
85
+ Displays all available configurations along with the active one.
86
+ """
87
+ service = get_service()
88
+ config_manager = service.get_config_manager()
89
+ configs = config_manager.list_configs()
90
+ active_config = config_manager.get_active_config_name()
91
+
92
+ table = Table(title="Available Configurations")
93
+ table.add_column("Name", style="cyan")
94
+ table.add_column("Path", style="green")
95
+ table.add_column("Status", style="magenta")
96
+
97
+ for name, path in configs.items():
98
+ status = "🟢 Active" if name == active_config else ""
99
+ table.add_row(name, path, status)
100
+
101
+ display(table)
102
+
103
+
104
+ @app.command()
105
+ def show(
106
+ name: Optional[str] = typer.Argument(
107
+ None, help="Name of the configuration to show. If not provided, shows the active configuration."
108
+ ),
109
+ ):
110
+ """
111
+ Show the content of a specific configuration.
112
+
113
+ Args:
114
+ name: Name of the configuration to show. Shows the active configuration if not specified.
115
+ """
116
+ service = get_service()
117
+ config_manager = service.get_config_manager()
118
+ if name is None:
119
+ name = config_manager.get_active_config_name()
120
+ console.print(f"Showing active configuration: [cyan]{name}[/cyan]")
121
+
122
+ configs = config_manager.list_configs()
123
+ if name not in configs:
124
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
125
+ raise typer.Exit(1)
126
+
127
+ config_path = configs[name]
128
+ try:
129
+ with open(config_path, "r") as f:
130
+ content = f.read()
131
+
132
+ syntax = Syntax(content, "toml", theme="monokai", line_numbers=True)
133
+ console.print(syntax)
134
+ except Exception as e:
135
+ console.print(f"[red]Error reading configuration file: {str(e)}[/red]")
136
+ raise typer.Exit(1)
137
+
138
+
139
+ @app.command()
140
+ def switch(name: str = typer.Argument(..., help="Name of the configuration to switch to.")):
141
+ """
142
+ Switch to a different configuration.
143
+
144
+ Args:
145
+ name: Name of the configuration to switch to.
146
+ """
147
+ service = get_service()
148
+ config_manager = service.get_config_manager()
149
+ configs = config_manager.list_configs()
150
+
151
+ if name not in configs:
152
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
153
+ raise typer.Exit(1)
154
+
155
+ try:
156
+ config_manager.switch_config(name)
157
+ console.print(f"[green]Successfully switched to configuration: [cyan]{name}[/cyan][/green]")
158
+
159
+ # Reset the state to use the new configuration
160
+ service.current_state = service.get_state()
161
+ except Exception as e:
162
+ console.print(f"[red]Error switching configuration: {str(e)}[/red]")
163
+ raise typer.Exit(1)
164
+
165
+
166
+ @app.command()
167
+ def create(
168
+ name: str = typer.Argument(..., help="Name for the new configuration."),
169
+ base: Optional[str] = typer.Option(
170
+ None, "--base", "-b", help="Base the new configuration on an existing one. Uses the default if not specified."
171
+ ),
172
+ switch_to: bool = typer.Option(False, "--switch", "-s", help="Switch to the new configuration after creating it."),
173
+ ):
174
+ """
175
+ Create a new configuration.
176
+
177
+ Args:
178
+ name: Name for the new configuration.
179
+ base: Base the new configuration on an existing one. Uses the default if not specified.
180
+ switch_to: Switch to the new configuration after creating it.
181
+ """
182
+ service = get_service()
183
+ config_manager = service.get_config_manager()
184
+ configs = config_manager.list_configs()
185
+
186
+ if name in configs:
187
+ console.print(f"[red]Error: Configuration '{name}' already exists.[/red]")
188
+ raise typer.Exit(1)
189
+
190
+ if base and base not in configs:
191
+ console.print(f"[red]Error: Base configuration '{base}' not found.[/red]")
192
+ raise typer.Exit(1)
193
+
194
+ try:
195
+ if base:
196
+ # Load the base configuration
197
+ base_config = config_manager.get_config(base)
198
+ config_data = base_config.dump()
199
+ else:
200
+ # Use default settings
201
+ config_data = ServiceSettings._get_default_settings()
202
+
203
+ # Add the new configuration
204
+ config_manager.add_config(name, config_data)
205
+ console.print(f"[green]Successfully created configuration: [cyan]{name}[/cyan][/green]")
206
+
207
+ if switch_to:
208
+ config_manager.switch_config(name)
209
+ console.print(f"[green]Switched to configuration: [cyan]{name}[/cyan][/green]")
210
+ # Reset the state to use the new configuration
211
+ service.current_state = service.get_state()
212
+
213
+ except Exception as e:
214
+ console.print(f"[red]Error creating configuration: {str(e)}[/red]")
215
+ raise typer.Exit(1)
216
+
217
+
218
+ @app.command()
219
+ def remove(name: str = typer.Argument(..., help="Name of the configuration to remove.")):
220
+ """
221
+ Remove a configuration.
222
+
223
+ Args:
224
+ name: Name of the configuration to remove.
225
+ """
226
+ service = get_service()
227
+ config_manager = service.get_config_manager()
228
+ configs = config_manager.list_configs()
229
+
230
+ if name not in configs:
231
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
232
+ raise typer.Exit(1)
233
+
234
+ if name == "default":
235
+ console.print("[red]Error: Cannot remove the default configuration.[/red]")
236
+ raise typer.Exit(1)
237
+
238
+ confirm = typer.confirm(f"Are you sure you want to remove configuration '{name}'?")
239
+ if not confirm:
240
+ console.print("Operation cancelled.")
241
+ return
242
+
243
+ try:
244
+ active_config = config_manager.get_active_config_name()
245
+ config_manager.remove_config(name)
246
+ console.print(f"[green]Successfully removed configuration: [cyan]{name}[/cyan][/green]")
247
+
248
+ if active_config == name:
249
+ console.print("[yellow]Active configuration was removed. Switched to default.[/yellow]")
250
+ # Reset the state to use the default configuration
251
+ service.current_state = service.get_state()
252
+ except Exception as e:
253
+ console.print(f"[red]Error removing configuration: {str(e)}[/red]")
254
+ raise typer.Exit(1)
255
+
256
+
257
+ @app.command()
258
+ def edit(
259
+ name: Optional[str] = typer.Argument(
260
+ None, help="Name of the configuration to edit. Edits the active configuration if not specified."
261
+ ),
262
+ editor: Optional[str] = typer.Option(
263
+ None, "--editor", "-e", help="Editor to use. Uses the system default if not specified."
264
+ ),
265
+ ):
266
+ """
267
+ Edit a configuration using the system's default editor or a specified one.
268
+
269
+ Args:
270
+ name: Name of the configuration to edit. Edits the active configuration if not specified.
271
+ editor: Editor to use. Uses the system default if not specified.
272
+ """
273
+ service = get_service()
274
+ config_manager = service.get_config_manager()
275
+ if name is None:
276
+ name = config_manager.get_active_config_name()
277
+ console.print(f"Editing active configuration: [cyan]{name}[/cyan]")
278
+
279
+ configs = config_manager.list_configs()
280
+ if name not in configs:
281
+ console.print(f"[red]Error: Configuration '{name}' not found.[/red]")
282
+ raise typer.Exit(1)
283
+
284
+ config_path = configs[name]
285
+
286
+ editor_cmd = editor or os.environ.get("EDITOR", "vim")
287
+ try:
288
+ # Launch the editor properly by using a list of arguments instead of a string
289
+ import subprocess
290
+
291
+ result = subprocess.run([editor_cmd, str(config_path)], check=True)
292
+ if result.returncode == 0:
293
+ console.print(f"[green]Successfully edited configuration: [cyan]{name}[/cyan][/green]")
294
+
295
+ # Reset the state if we edited the active configuration
296
+ if name == config_manager.get_active_config_name():
297
+ service.current_state = service.get_state()
298
+ else:
299
+ console.print("[yellow]Editor exited with an error.[/yellow]")
300
+ except Exception as e:
301
+ console.print(f"[red]Error launching editor: {str(e)}[/red]")
302
+ raise typer.Exit(1)
303
+
304
+
305
+ @app.command()
306
+ def set(
307
+ changes: List[str] = typer.Argument(
308
+ ...,
309
+ help="Changes in format 'component:key=value', where component is 'universe' or a project name. Multiple can be specified.",
310
+ ),
311
+ config_name: Optional[str] = typer.Option(
312
+ None,
313
+ "--config",
314
+ "-c",
315
+ help="Name of the configuration to modify. Modifies the active configuration if not specified.",
316
+ ),
317
+ ):
318
+ """
319
+ Modify configuration settings using a consistent syntax for universe and projects.
320
+
321
+ Args:
322
+ changes: List of changes in format 'component:key=value'. For example:
323
+ 'universe:branch=main' - Change the universe branch
324
+ 'cmip6:github_repo=https://github.com/new/repo' - Change a project's repository
325
+ config_name: Name of the configuration to modify. Modifies the active configuration if not specified.
326
+
327
+ Examples:
328
+ # Change the universe branch in the active configuration
329
+ esgvoc config set 'universe:branch=esgvoc_dev'
330
+
331
+ # Enable offline mode for universe
332
+ esgvoc config set 'universe:offline_mode=true'
333
+
334
+ # Enable offline mode for a specific project
335
+ esgvoc config set 'cmip6:offline_mode=true'
336
+
337
+ # Change multiple components at once
338
+ esgvoc config set 'universe:branch=esgvoc_dev' 'cmip6:branch=esgvoc_dev'
339
+
340
+ # Change settings in a specific configuration
341
+ esgvoc config set 'universe:local_path=repos/prod/universe' --config prod
342
+
343
+ # Change the GitHub repository URL for a project
344
+ esgvoc config set 'cmip6:github_repo=https://github.com/WCRP-CMIP/CMIP6_CVs_new'
345
+ """
346
+ service = get_service()
347
+ config_manager = service.get_config_manager()
348
+ if config_name is None:
349
+ config_name = config_manager.get_active_config_name()
350
+ console.print(f"Modifying active configuration: [cyan]{config_name}[/cyan]")
351
+
352
+ configs = config_manager.list_configs()
353
+ if config_name not in configs:
354
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
355
+ raise typer.Exit(1)
356
+
357
+ try:
358
+ # Load the configuration
359
+ config = config_manager.get_config(config_name)
360
+ modified = False
361
+
362
+ # Process all changes with the same format
363
+ for change in changes:
364
+ try:
365
+ # Format should be component:setting=value (where component is 'universe' or a project name)
366
+ component_part, setting_part = change.split(":", 1)
367
+ setting_key, setting_value = setting_part.split("=", 1)
368
+
369
+ # Handle universe settings
370
+ if component_part == "universe":
371
+ if setting_key == "github_repo":
372
+ config.universe.github_repo = setting_value
373
+ modified = True
374
+ elif setting_key == "branch":
375
+ config.universe.branch = setting_value
376
+ modified = True
377
+ elif setting_key == "local_path":
378
+ config.universe.local_path = setting_value
379
+ modified = True
380
+ elif setting_key == "db_path":
381
+ config.universe.db_path = setting_value
382
+ modified = True
383
+ elif setting_key == "offline_mode":
384
+ config.universe.offline_mode = setting_value.lower() in ("true", "1", "yes", "on")
385
+ modified = True
386
+ else:
387
+ console.print(f"[yellow]Warning: Unknown universe setting '{setting_key}'. Skipping.[/yellow]")
388
+ continue
389
+
390
+ console.print(f"[green]Updated universe.{setting_key} = {setting_value}[/green]")
391
+
392
+ # Handle project settings using the new update_project method
393
+ elif component_part in config.projects:
394
+ # Use the new update_project method
395
+ if config.update_project(component_part, **{setting_key: setting_value}):
396
+ modified = True
397
+ console.print(f"[green]Updated {component_part}.{setting_key} = {setting_value}[/green]")
398
+ else:
399
+ console.print(f"[yellow]Warning: Unknown project setting '{setting_key}'. Skipping.[/yellow]")
400
+ else:
401
+ console.print(
402
+ f"[yellow]Warning: Component '{component_part}' not found in configuration. Skipping.[/yellow]"
403
+ )
404
+ continue
405
+
406
+ except ValueError:
407
+ console.print(
408
+ f"[yellow]Warning: Invalid change format '{change}'. Should be 'component:key=value'. Skipping.[/yellow]"
409
+ )
410
+
411
+ if modified:
412
+ # Save the modified configuration
413
+ config_manager.save_active_config(config)
414
+ console.print(f"[green]Successfully updated configuration: [cyan]{config_name}[/cyan][/green]")
415
+
416
+ # Reset the state if we modified the active configuration
417
+ if config_name == config_manager.get_active_config_name():
418
+ service.current_state = service.get_state()
419
+ else:
420
+ console.print("[yellow]No changes were made to the configuration.[/yellow]")
421
+
422
+ except Exception as e:
423
+ console.print(f"[red]Error updating configuration: {str(e)}[/red]")
424
+ raise typer.Exit(1)
425
+
426
+
427
+ # 🔹 NEW: Enhanced project management commands using ServiceSettings methods
428
+
429
+
430
+ @app.command()
431
+ def list_available_projects():
432
+ """
433
+ List all available default projects that can be added.
434
+ """
435
+ available_projects = ServiceSettings._get_default_project_configs()
436
+
437
+ table = Table(title="Available Default Projects")
438
+ table.add_column("Project Name", style="cyan")
439
+ table.add_column("Repository", style="green")
440
+ table.add_column("Branch", style="yellow")
441
+
442
+ for project_name, config in available_projects.items():
443
+ table.add_row(project_name, config["github_repo"], config["branch"])
444
+
445
+ display(table)
446
+
447
+
448
+ @app.command()
449
+ def list_projects(
450
+ config_name: Optional[str] = typer.Option(
451
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
452
+ ),
453
+ ):
454
+ """
455
+ List all projects in a configuration.
456
+ """
457
+ service = get_service()
458
+ config_manager = service.get_config_manager()
459
+ if config_name is None:
460
+ config_name = config_manager.get_active_config_name()
461
+ console.print(f"Showing projects in active configuration: [cyan]{config_name}[/cyan]")
462
+
463
+ configs = config_manager.list_configs()
464
+ if config_name not in configs:
465
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
466
+ raise typer.Exit(1)
467
+
468
+ try:
469
+ config = config_manager.get_config(config_name)
470
+
471
+ if not config.projects:
472
+ console.print(f"[yellow]No projects found in configuration '{config_name}'.[/yellow]")
473
+ return
474
+
475
+ table = Table(title=f"Projects in Configuration: {config_name}")
476
+ table.add_column("Project Name", style="cyan")
477
+ table.add_column("Repository", style="green")
478
+ table.add_column("Branch", style="yellow")
479
+ table.add_column("Local Path", style="blue")
480
+ table.add_column("DB Path", style="magenta")
481
+
482
+ for project_name, project in config.projects.items():
483
+ table.add_row(
484
+ project_name,
485
+ project.github_repo,
486
+ project.branch or "main",
487
+ project.local_path or "N/A",
488
+ project.db_path or "N/A",
489
+ )
490
+
491
+ display(table)
492
+
493
+ except Exception as e:
494
+ console.print(f"[red]Error listing projects: {str(e)}[/red]")
495
+ raise typer.Exit(1)
496
+
497
+
498
+ @app.command()
499
+ def add_project(
500
+ project_name: str = typer.Argument(..., help="Name of the project to add."),
501
+ config_name: Optional[str] = typer.Option(
502
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
503
+ ),
504
+ from_default: bool = typer.Option(
505
+ True, "--from-default/--custom", help="Add from default configuration or specify custom settings."
506
+ ),
507
+ # Custom project options (only used when --custom is specified)
508
+ github_repo: Optional[str] = typer.Option(
509
+ None, "--repo", "-r", help="GitHub repository URL (for custom projects)."
510
+ ),
511
+ branch: Optional[str] = typer.Option("main", "--branch", "-b", help="Branch (for custom projects)."),
512
+ local_path: Optional[str] = typer.Option(None, "--local", "-l", help="Local path (for custom projects)."),
513
+ db_path: Optional[str] = typer.Option(None, "--db", "-d", help="Database path (for custom projects)."),
514
+ ):
515
+ """
516
+ Add a project to a configuration.
517
+
518
+ By default, adds from available default projects. Use --custom to specify custom settings.
519
+
520
+ Examples:
521
+ # Add a default project
522
+ esgvoc add-project input4mip
523
+
524
+ # Add a custom project
525
+ esgvoc add-project my_project --custom --repo https://github.com/me/repo
526
+ """
527
+ service = get_service()
528
+ config_manager = service.get_config_manager()
529
+ if config_name is None:
530
+ config_name = config_manager.get_active_config_name()
531
+ console.print(f"Modifying active configuration: [cyan]{config_name}[/cyan]")
532
+
533
+ configs = config_manager.list_configs()
534
+ if config_name not in configs:
535
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
536
+ raise typer.Exit(1)
537
+
538
+ try:
539
+ # 🔹 FORCE FRESH LOAD: Load configuration directly from file to bypass any caching
540
+ configs = config_manager.list_configs()
541
+ config_path = configs[config_name]
542
+
543
+ # Load fresh configuration from file
544
+ try:
545
+ config = ServiceSettings.load_from_file(config_path)
546
+ console.print(f"[blue]Debug: Loaded fresh config from file[/blue]")
547
+ except Exception as e:
548
+ console.print(f"[yellow]Debug: Failed to load from file ({e}), using config manager[/yellow]")
549
+ config = config_manager.get_config(config_name)
550
+
551
+ # 🔹 DEBUG: Show current projects before adding
552
+ current_projects = []
553
+ if hasattr(config, "projects") and config.projects:
554
+ current_projects = [name for name in config.projects.keys()]
555
+ console.print(f"[blue]Debug: Current projects: {current_projects}[/blue]")
556
+
557
+ if from_default:
558
+ # Add from default configuration
559
+ if config.add_project_from_default(project_name):
560
+ console.print(
561
+ f"[green]Successfully added default project [cyan]{project_name}[/cyan] to configuration [cyan]{config_name}[/cyan][/green]"
562
+ )
563
+ else:
564
+ if config.has_project(project_name):
565
+ console.print(
566
+ f"[red]Error: Project '{project_name}' already exists in configuration '{config_name}'.[/red]"
567
+ )
568
+ else:
569
+ available = config.get_available_default_projects()
570
+ console.print(f"[red]Error: '{project_name}' is not a valid default project.[/red]")
571
+ console.print(f"[yellow]Available default projects: {', '.join(available)}[/yellow]")
572
+ raise typer.Exit(1)
573
+ else:
574
+ # Add custom project
575
+ if not github_repo:
576
+ console.print("[red]Error: --repo is required when adding custom projects.[/red]")
577
+ raise typer.Exit(1)
578
+
579
+ # Set default paths if not provided
580
+ if local_path is None:
581
+ local_path = f"repos/{project_name}"
582
+ if db_path is None:
583
+ db_path = f"dbs/{project_name}.sqlite"
584
+
585
+ custom_config = {
586
+ "project_name": project_name,
587
+ "github_repo": github_repo,
588
+ "branch": branch,
589
+ "local_path": local_path,
590
+ "db_path": db_path,
591
+ }
592
+
593
+ if config.add_project_custom(custom_config):
594
+ console.print(
595
+ f"[green]Successfully added custom project [cyan]{project_name}[/cyan] to configuration [cyan]{config_name}[/cyan][/green]"
596
+ )
597
+ else:
598
+ console.print(
599
+ f"[red]Error: Project '{project_name}' already exists in configuration '{config_name}'.[/red]"
600
+ )
601
+ raise typer.Exit(1)
602
+
603
+ # Save the configuration
604
+ config_manager.save_active_config(config)
605
+
606
+ # Reset the state if we modified the active configuration
607
+ if config_name == config_manager.get_active_config_name():
608
+ service.current_state = service.get_state()
609
+
610
+ except ValueError as e:
611
+ console.print(f"[red]Error: {str(e)}[/red]")
612
+ raise typer.Exit(1)
613
+ except Exception as e:
614
+ console.print(f"[red]Error adding project: {str(e)}[/red]")
615
+ raise typer.Exit(1)
616
+
617
+
618
+ @app.command()
619
+ def remove_project(
620
+ project_name: str = typer.Argument(..., help="Name of the project to remove."),
621
+ config_name: Optional[str] = typer.Option(
622
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
623
+ ),
624
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
625
+ ):
626
+ """
627
+ Remove a project from a configuration.
628
+ """
629
+ service = get_service()
630
+ config_manager = service.get_config_manager()
631
+ if config_name is None:
632
+ config_name = config_manager.get_active_config_name()
633
+ console.print(f"Modifying active configuration: [cyan]{config_name}[/cyan]")
634
+
635
+ configs = config_manager.list_configs()
636
+ if config_name not in configs:
637
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
638
+ raise typer.Exit(1)
639
+
640
+ try:
641
+ # 🔹 FORCE FRESH LOAD for removal too
642
+ configs = config_manager.list_configs()
643
+ config_path = configs[config_name]
644
+
645
+ try:
646
+ config = ServiceSettings.load_from_file(config_path)
647
+ console.print(f"[blue]Debug: Loaded fresh config from file for removal[/blue]")
648
+ except Exception as e:
649
+ console.print(f"[yellow]Debug: Failed to load from file ({e}), using config manager[/yellow]")
650
+ config = config_manager.get_config(config_name)
651
+
652
+ if not config.has_project(project_name):
653
+ console.print(f"[red]Error: Project '{project_name}' not found in configuration '{config_name}'.[/red]")
654
+ raise typer.Exit(1)
655
+
656
+ # Confirm removal unless forced
657
+ if not force:
658
+ confirm = typer.confirm(
659
+ f"Are you sure you want to remove project '{project_name}' from configuration '{config_name}'?"
660
+ )
661
+ if not confirm:
662
+ console.print("Operation cancelled.")
663
+ return
664
+
665
+ # Remove project using the new method
666
+ if config.remove_project(project_name):
667
+ console.print(
668
+ f"[green]Successfully removed project [cyan]{project_name}[/cyan] from configuration [cyan]{config_name}[/cyan][/green]"
669
+ )
670
+ else:
671
+ console.print(f"[red]Error: Failed to remove project '{project_name}'.[/red]")
672
+ raise typer.Exit(1)
673
+
674
+ # Save the configuration
675
+ config_manager.save_active_config(config)
676
+
677
+ # 🔹 DEBUG: Verify the project was actually removed
678
+ remaining_projects = []
679
+ if hasattr(config, "projects") and config.projects:
680
+ remaining_projects = [name for name in config.projects.keys()]
681
+ console.print(f"[blue]Debug: Projects after removal: {remaining_projects}[/blue]")
682
+
683
+ # Reset the state if we modified the active configuration
684
+ if config_name == config_manager.get_active_config_name():
685
+ service.current_state = service.get_state()
686
+
687
+ except Exception as e:
688
+ console.print(f"[red]Error removing project: {str(e)}[/red]")
689
+ raise typer.Exit(1)
690
+
691
+
692
+ @app.command()
693
+ def update_project(
694
+ project_name: str = typer.Argument(..., help="Name of the project to update."),
695
+ config_name: Optional[str] = typer.Option(
696
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
697
+ ),
698
+ github_repo: Optional[str] = typer.Option(None, "--repo", "-r", help="New GitHub repository URL."),
699
+ branch: Optional[str] = typer.Option(None, "--branch", "-b", help="New branch."),
700
+ local_path: Optional[str] = typer.Option(None, "--local", "-l", help="New local path."),
701
+ db_path: Optional[str] = typer.Option(None, "--db", "-d", help="New database path."),
702
+ ):
703
+ """
704
+ Update settings for an existing project.
705
+ """
706
+ service = get_service()
707
+ config_manager = service.get_config_manager()
708
+ if config_name is None:
709
+ config_name = config_manager.get_active_config_name()
710
+ console.print(f"Modifying active configuration: [cyan]{config_name}[/cyan]")
711
+
712
+ configs = config_manager.list_configs()
713
+ if config_name not in configs:
714
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
715
+ raise typer.Exit(1)
716
+
717
+ try:
718
+ config = config_manager.get_config(config_name)
719
+
720
+ if not config.has_project(project_name):
721
+ console.print(f"[red]Error: Project '{project_name}' not found in configuration '{config_name}'.[/red]")
722
+ raise typer.Exit(1)
723
+
724
+ # Build update dict with non-None values
725
+ updates = {}
726
+ if github_repo is not None:
727
+ updates["github_repo"] = github_repo
728
+ if branch is not None:
729
+ updates["branch"] = branch
730
+ if local_path is not None:
731
+ updates["local_path"] = local_path
732
+ if db_path is not None:
733
+ updates["db_path"] = db_path
734
+
735
+ if not updates:
736
+ console.print(
737
+ "[yellow]No updates specified. Use --repo, --branch, --local, or --db to specify changes.[/yellow]"
738
+ )
739
+ return
740
+
741
+ # Update project using the new method
742
+ if config.update_project(project_name, **updates):
743
+ console.print(
744
+ f"[green]Successfully updated project [cyan]{project_name}[/cyan] in configuration [cyan]{config_name}[/cyan][/green]"
745
+ )
746
+ for key, value in updates.items():
747
+ console.print(f" [green]{key} = {value}[/green]")
748
+ else:
749
+ console.print(f"[red]Error: Failed to update project '{project_name}'.[/red]")
750
+ raise typer.Exit(1)
751
+
752
+ # Save the configuration
753
+ config_manager.save_active_config(config)
754
+
755
+ # Reset the state if we modified the active configuration
756
+ if config_name == config_manager.get_active_config_name():
757
+ service.current_state = service.get_state()
758
+
759
+ except Exception as e:
760
+ console.print(f"[red]Error updating project: {str(e)}[/red]")
761
+ raise typer.Exit(1)
762
+
763
+
764
+ # 🔹 NEW: Simple config management commands
765
+
766
+
767
+ @app.command()
768
+ def add(
769
+ project_names: List[str] = typer.Argument(..., help="Names of the projects to add from defaults."),
770
+ config_name: Optional[str] = typer.Option(
771
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
772
+ ),
773
+ ):
774
+ """
775
+ Add one or more default projects to the current configuration and install their CVs.
776
+
777
+ This will:
778
+ 1. Add the projects to the configuration using default settings
779
+ 2. Download the projects' CVs by running synchronize_all
780
+
781
+ Examples:
782
+ esgvoc config add input4mip
783
+ esgvoc config add input4mip obs4mip cordex-cmip6
784
+ esgvoc config add obs4mip --config my_config
785
+ """
786
+ service = get_service()
787
+ config_manager = service.get_config_manager()
788
+ if config_name is None:
789
+ config_name = config_manager.get_active_config_name()
790
+ console.print(f"Adding to active configuration: [cyan]{config_name}[/cyan]")
791
+
792
+ configs = config_manager.list_configs()
793
+ if config_name not in configs:
794
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
795
+ raise typer.Exit(1)
796
+
797
+ try:
798
+ # Load fresh configuration from file
799
+ configs = config_manager.list_configs()
800
+ config_path = configs[config_name]
801
+ config = ServiceSettings.load_from_file(config_path)
802
+
803
+ added_projects = []
804
+ skipped_projects = []
805
+ invalid_projects = []
806
+
807
+ # Process each project
808
+ for project_name in project_names:
809
+ # Check if project already exists
810
+ if config.has_project(project_name):
811
+ skipped_projects.append(project_name)
812
+ console.print(f"[yellow]⚠ Project '{project_name}' already exists - skipping[/yellow]")
813
+ continue
814
+
815
+ # Add the project from defaults
816
+ try:
817
+ if config.add_project_from_default(project_name):
818
+ added_projects.append(project_name)
819
+ console.print(f"[green]✓ Added project [cyan]{project_name}[/cyan][/green]")
820
+ else:
821
+ invalid_projects.append(project_name)
822
+ console.print(f"[red]✗ Invalid project '{project_name}'[/red]")
823
+ except ValueError as e:
824
+ invalid_projects.append(project_name)
825
+ console.print(f"[red]✗ Invalid project '{project_name}'[/red]")
826
+
827
+ # Show summary of what was processed
828
+ if added_projects:
829
+ console.print(
830
+ f"[green]Successfully added {len(added_projects)} project(s): {', '.join(added_projects)}[/green]"
831
+ )
832
+ if skipped_projects:
833
+ console.print(
834
+ f"[yellow]Skipped {len(skipped_projects)} existing project(s): {', '.join(skipped_projects)}[/yellow]"
835
+ )
836
+ if invalid_projects:
837
+ available = config.get_available_default_projects()
838
+ console.print(f"[red]Invalid project(s): {', '.join(invalid_projects)}[/red]")
839
+ console.print(f"[yellow]Available projects: {', '.join(available)}[/yellow]")
840
+
841
+ # Only proceed if we actually added something
842
+ if added_projects:
843
+ # Save the configuration to the correct file
844
+ if config_name == config_manager.get_active_config_name():
845
+ config_manager.save_active_config(config)
846
+ # Reset the state if we modified the active configuration
847
+ service.current_state = service.get_state()
848
+ else:
849
+ # Save to specific config file
850
+ config_path = configs[config_name]
851
+ config.save_to_file(config_path)
852
+
853
+ # Download the CVs for all added projects
854
+ console.print(f"[blue]Downloading CVs for {len(added_projects)} project(s)...[/blue]")
855
+ service.current_state.synchronize_all()
856
+ console.print(f"[green]✓ Successfully installed CVs for all added projects[/green]")
857
+ elif invalid_projects and not skipped_projects:
858
+ # Exit with error only if we had invalid projects and nothing was skipped
859
+ raise typer.Exit(1)
860
+
861
+ except ValueError as e:
862
+ console.print(f"[red]Error: {str(e)}[/red]")
863
+ raise typer.Exit(1)
864
+ except Exception as e:
865
+ console.print(f"[red]Error adding project: {str(e)}[/red]")
866
+ raise typer.Exit(1)
867
+
868
+
869
+ @app.command()
870
+ def rm(
871
+ project_names: List[str] = typer.Argument(..., help="Names of the projects to remove."),
872
+ config_name: Optional[str] = typer.Option(
873
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
874
+ ),
875
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
876
+ keep_files: bool = typer.Option(
877
+ False, "--keep-files", help="Keep local repos and databases (only remove from config)."
878
+ ),
879
+ ):
880
+ """
881
+ Remove one or more projects from the configuration and delete their repos/databases.
882
+
883
+ This will:
884
+ 1. Remove the projects from the configuration
885
+ 2. Delete the local repository directories (unless --keep-files)
886
+ 3. Delete the database files (unless --keep-files)
887
+
888
+ Examples:
889
+ esgvoc config rm input4mip
890
+ esgvoc config rm input4mip obs4mip cordex-cmip6
891
+ esgvoc config rm obs4mip --force
892
+ esgvoc config rm cmip6 input4mip --keep-files # Remove from config but keep files
893
+ """
894
+ service = get_service()
895
+ config_manager = service.get_config_manager()
896
+ if config_name is None:
897
+ config_name = config_manager.get_active_config_name()
898
+ console.print(f"Removing from active configuration: [cyan]{config_name}[/cyan]")
899
+
900
+ configs = config_manager.list_configs()
901
+ if config_name not in configs:
902
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
903
+ raise typer.Exit(1)
904
+
905
+ try:
906
+ # Load fresh configuration from file
907
+ configs = config_manager.list_configs()
908
+ config_path = configs[config_name]
909
+ config = ServiceSettings.load_from_file(config_path)
910
+
911
+ # Check which projects exist and collect their details
912
+ valid_projects = []
913
+ invalid_projects = []
914
+ projects_to_remove = {} # project_name -> project_object
915
+
916
+ for project_name in project_names:
917
+ if config.has_project(project_name):
918
+ project = config.get_project(project_name)
919
+ projects_to_remove[project_name] = project
920
+ valid_projects.append(project_name)
921
+ else:
922
+ invalid_projects.append(project_name)
923
+ console.print(f"[red]✗ Project '{project_name}' not found in configuration[/red]")
924
+
925
+ if invalid_projects:
926
+ console.print(f"[red]Invalid project(s): {', '.join(invalid_projects)}[/red]")
927
+
928
+ if not valid_projects:
929
+ console.print("[red]No valid projects to remove.[/red]")
930
+ raise typer.Exit(1)
931
+
932
+ # Show what will be removed and confirm unless forced
933
+ console.print(f"[yellow]Projects to remove: {', '.join(valid_projects)}[/yellow]")
934
+ if not force:
935
+ action_desc = "remove from config only" if keep_files else "remove from config and delete all files"
936
+ project_word = "project" if len(valid_projects) == 1 else "projects"
937
+ confirm = typer.confirm(f"Are you sure you want to {action_desc} for {len(valid_projects)} {project_word}?")
938
+ if not confirm:
939
+ console.print("Operation cancelled.")
940
+ return
941
+
942
+ # Get base directory for file cleanup
943
+ base_dir = config_manager.data_config_dir or str(config_manager.data_dir)
944
+
945
+ removed_projects = []
946
+ # Remove each project
947
+ for project_name in valid_projects:
948
+ project = projects_to_remove[project_name]
949
+
950
+ if config.remove_project(project_name):
951
+ removed_projects.append(project_name)
952
+ console.print(f"[green]✓ Removed [cyan]{project_name}[/cyan] from configuration[/green]")
953
+
954
+ # Clean up filesystem unless --keep-files
955
+ if not keep_files and project:
956
+ # Clean up local repository
957
+ if project.local_path:
958
+ repo_path = Path(base_dir) / project.local_path
959
+ if repo_path.exists():
960
+ shutil.rmtree(repo_path)
961
+ console.print(f"[green] ✓ Deleted repository: {repo_path}[/green]")
962
+ else:
963
+ console.print(f"[yellow] Repository not found: {repo_path}[/yellow]")
964
+
965
+ # Clean up database
966
+ if project.db_path:
967
+ db_path = Path(base_dir) / project.db_path
968
+ if db_path.exists():
969
+ db_path.unlink()
970
+ console.print(f"[green] ✓ Deleted database: {db_path}[/green]")
971
+ else:
972
+ console.print(f"[yellow] Database not found: {db_path}[/yellow]")
973
+ else:
974
+ console.print(f"[red]✗ Failed to remove '{project_name}'[/red]")
975
+
976
+ if removed_projects:
977
+ console.print(
978
+ f"[green]Successfully removed {len(removed_projects)} project(s): {', '.join(removed_projects)}[/green]"
979
+ )
980
+
981
+ # Save the configuration to the correct file
982
+ if config_name == config_manager.get_active_config_name():
983
+ config_manager.save_active_config(config)
984
+ # Reset the state if we modified the active configuration
985
+ service.current_state = service.get_state()
986
+ else:
987
+ # Save to specific config file
988
+ config_path = configs[config_name]
989
+ config.save_to_file(config_path)
990
+ else:
991
+ console.print("[red]No projects were successfully removed.[/red]")
992
+ raise typer.Exit(1)
993
+
994
+ except Exception as e:
995
+ console.print(f"[red]Error removing project: {str(e)}[/red]")
996
+ raise typer.Exit(1)
997
+
998
+
999
+ @app.command()
1000
+ def init(
1001
+ name: str = typer.Argument(..., help="Name for the new empty configuration."),
1002
+ no_switch: bool = typer.Option(
1003
+ False, "--no-switch", help="Don't switch to the new configuration (stays on current)."
1004
+ ),
1005
+ ):
1006
+ """
1007
+ Create a new empty configuration with only universe settings (no projects).
1008
+
1009
+ This creates a minimal configuration with just the universe component,
1010
+ allowing you to add projects selectively using 'esgvoc config add'.
1011
+ By default, switches to the new configuration after creation.
1012
+
1013
+ Examples:
1014
+ esgvoc config init minimal
1015
+ esgvoc config init test --no-switch # Create but don't switch
1016
+ """
1017
+ service = get_service()
1018
+ config_manager = service.get_config_manager()
1019
+ configs = config_manager.list_configs()
1020
+
1021
+ if name in configs:
1022
+ console.print(f"[red]Error: Configuration '{name}' already exists.[/red]")
1023
+ raise typer.Exit(1)
1024
+
1025
+ try:
1026
+ # Create empty configuration with only universe settings
1027
+ default_settings = ServiceSettings._get_default_settings()
1028
+ empty_config_data = {
1029
+ "universe": default_settings["universe"],
1030
+ "projects": [], # No projects - completely empty
1031
+ }
1032
+
1033
+ # Add the new configuration
1034
+ config_manager.add_config(name, empty_config_data)
1035
+ console.print(f"[green]✓ Created empty configuration: [cyan]{name}[/cyan][/green]")
1036
+
1037
+ # Switch to new config by default (unless --no-switch is used)
1038
+ if not no_switch:
1039
+ config_manager.switch_config(name)
1040
+ console.print(f"[green]✓ Switched to configuration: [cyan]{name}[/cyan][/green]")
1041
+ # Reset the state to use the new configuration
1042
+ service.current_state = service.get_state()
1043
+
1044
+ except Exception as e:
1045
+ console.print(f"[red]Error creating configuration: {str(e)}[/red]")
1046
+ raise typer.Exit(1)
1047
+
1048
+
1049
+ @app.command()
1050
+ def migrate(
1051
+ config_name: Optional[str] = typer.Option(
1052
+ None, "--config", "-c", help="Configuration name to migrate. Migrates all configs if not specified."
1053
+ ),
1054
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be changed without making changes."),
1055
+ ):
1056
+ """
1057
+ Migrate configuration(s) to convert relative paths to absolute paths.
1058
+
1059
+ This command is needed when upgrading to newer versions that require absolute paths.
1060
+ By default, migrates all configurations. Use --config to migrate only a specific one.
1061
+
1062
+ Examples:
1063
+ esgvoc config migrate # Migrate all configurations
1064
+ esgvoc config migrate --config user_config # Migrate specific configuration
1065
+ esgvoc config migrate --dry-run # Show what would be changed
1066
+ """
1067
+ import os
1068
+ from pathlib import Path
1069
+
1070
+ # Enable migration mode to allow loading configs with relative paths
1071
+ os.environ['ESGVOC_MIGRATION_MODE'] = '1'
1072
+
1073
+ try:
1074
+ # Use config manager directly to avoid service initialization issues
1075
+ from esgvoc.core.service.configuration.config_manager import ConfigManager
1076
+ config_manager = ConfigManager(ServiceSettings, app_name="esgvoc", app_author="ipsl", default_settings=ServiceSettings._get_default_settings())
1077
+ configs = config_manager.list_configs()
1078
+
1079
+ # Determine which configs to migrate
1080
+ if config_name:
1081
+ if config_name not in configs:
1082
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
1083
+ raise typer.Exit(1)
1084
+ configs_to_migrate = {config_name: configs[config_name]}
1085
+ else:
1086
+ configs_to_migrate = configs
1087
+
1088
+ console.print(f"[blue]Migrating {len(configs_to_migrate)} configuration(s)...[/blue]")
1089
+
1090
+ migrated_count = 0
1091
+ for name, config_path in configs_to_migrate.items():
1092
+ console.print(f"\n[cyan]Processing configuration: {name}[/cyan]")
1093
+
1094
+ try:
1095
+ # Load the raw TOML data first to check for relative paths
1096
+ with open(config_path, 'r') as f:
1097
+ raw_data = toml.load(f)
1098
+
1099
+ changes_made = []
1100
+
1101
+ # Check universe paths
1102
+ if 'universe' in raw_data:
1103
+ universe = raw_data['universe']
1104
+ for path_field in ['local_path', 'db_path']:
1105
+ if path_field in universe and universe[path_field]:
1106
+ path_val = universe[path_field]
1107
+ if not Path(path_val).is_absolute():
1108
+ changes_made.append(f"universe.{path_field}: {path_val} -> <absolute>")
1109
+
1110
+ # Check project paths
1111
+ if 'projects' in raw_data:
1112
+ for project in raw_data['projects']:
1113
+ project_name = project.get('project_name', 'unknown')
1114
+ for path_field in ['local_path', 'db_path']:
1115
+ if path_field in project and project[path_field]:
1116
+ path_val = project[path_field]
1117
+ if not Path(path_val).is_absolute():
1118
+ changes_made.append(f"{project_name}.{path_field}: {path_val} -> <absolute>")
1119
+
1120
+ if changes_made:
1121
+ console.print(f"[yellow]Found {len(changes_made)} relative paths to migrate:[/yellow]")
1122
+ for change in changes_made:
1123
+ console.print(f" • {change}")
1124
+
1125
+ if not dry_run:
1126
+ # Load using ServiceSettings which will auto-convert to absolute paths
1127
+ migrated_config = ServiceSettings.load_from_file(config_path)
1128
+
1129
+ # Save back to file (now with absolute paths)
1130
+ migrated_config.save_to_file(config_path)
1131
+ console.print(f"[green]✓ Successfully migrated configuration: {name}[/green]")
1132
+ migrated_count += 1
1133
+ else:
1134
+ console.print(f"[blue] (dry-run: would migrate configuration: {name})[/blue]")
1135
+ migrated_count += 1
1136
+ else:
1137
+ console.print(f"[dim]No relative paths found in {name} - already migrated[/dim]")
1138
+
1139
+ except Exception as e:
1140
+ console.print(f"[red]Error processing {name}: {str(e)}[/red]")
1141
+ continue
1142
+
1143
+ # Summary
1144
+ action = "would be migrated" if dry_run else "migrated"
1145
+ if migrated_count > 0:
1146
+ console.print(f"\n[green]✓ {migrated_count} configuration(s) {action} successfully[/green]")
1147
+ if not dry_run:
1148
+ console.print("[blue]All relative paths have been converted to absolute paths.[/blue]")
1149
+ console.print("[blue]You can now use the configuration system normally.[/blue]")
1150
+ else:
1151
+ console.print(f"\n[blue]No configurations needed migration - all paths are already absolute[/blue]")
1152
+
1153
+ except Exception as e:
1154
+ console.print(f"[red]Error during migration: {str(e)}[/red]")
1155
+ raise typer.Exit(1)
1156
+ finally:
1157
+ # Disable migration mode
1158
+ if 'ESGVOC_MIGRATION_MODE' in os.environ:
1159
+ del os.environ['ESGVOC_MIGRATION_MODE']
1160
+
1161
+
1162
+ @app.command()
1163
+ def offline(
1164
+ enable: Optional[bool] = typer.Option(
1165
+ None, "--enable/--disable", help="Enable or disable offline mode. If not specified, shows current status."
1166
+ ),
1167
+ component: Optional[str] = typer.Option(
1168
+ "universe", "--component", "-c", help="Component to modify: 'universe' or project name (default: universe)"
1169
+ ),
1170
+ config_name: Optional[str] = typer.Option(
1171
+ None, "--config", help="Configuration name. Uses active configuration if not specified."
1172
+ ),
1173
+ ):
1174
+ """
1175
+ Enable, disable, or show offline mode status.
1176
+
1177
+ Examples:
1178
+ esgvoc config offline --enable # Enable offline mode for universe
1179
+ esgvoc config offline --disable # Disable offline mode for universe
1180
+ esgvoc config offline --enable -c cmip6 # Enable offline mode for cmip6 project
1181
+ esgvoc config offline # Show current offline mode status
1182
+ """
1183
+ service = get_service()
1184
+ config_manager = service.get_config_manager()
1185
+
1186
+ if config_name is None:
1187
+ config_name = config_manager.get_active_config_name()
1188
+
1189
+ configs = config_manager.list_configs()
1190
+ if config_name not in configs:
1191
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
1192
+ raise typer.Exit(1)
1193
+
1194
+ try:
1195
+ config = config_manager.get_config(config_name)
1196
+
1197
+ if enable is None:
1198
+ # Show current status
1199
+ if component == "universe":
1200
+ status = "enabled" if config.universe.offline_mode else "disabled"
1201
+ console.print(f"Universe offline mode is [cyan]{status}[/cyan] in configuration '{config_name}'")
1202
+ elif component in config.projects:
1203
+ status = "enabled" if config.projects[component].offline_mode else "disabled"
1204
+ console.print(f"Project '{component}' offline mode is [cyan]{status}[/cyan] in configuration '{config_name}'")
1205
+ else:
1206
+ console.print(f"[red]Error: Component '{component}' not found.[/red]")
1207
+ raise typer.Exit(1)
1208
+ else:
1209
+ # Update offline mode
1210
+ if component == "universe":
1211
+ config.universe.offline_mode = enable
1212
+ status = "enabled" if enable else "disabled"
1213
+ console.print(f"[green]Universe offline mode {status} in configuration '{config_name}'[/green]")
1214
+ elif component in config.projects:
1215
+ config.projects[component].offline_mode = enable
1216
+ status = "enabled" if enable else "disabled"
1217
+ console.print(f"[green]Project '{component}' offline mode {status} in configuration '{config_name}'[/green]")
1218
+ else:
1219
+ console.print(f"[red]Error: Component '{component}' not found.[/red]")
1220
+ raise typer.Exit(1)
1221
+
1222
+ # Save the configuration
1223
+ config_manager.save_active_config(config)
1224
+
1225
+ # Reset the state if we modified the active configuration
1226
+ if config_name == config_manager.get_active_config_name():
1227
+ service.current_state = service.get_state()
1228
+
1229
+ except Exception as e:
1230
+ console.print(f"[red]Error managing offline mode: {str(e)}[/red]")
1231
+ raise typer.Exit(1)
1232
+
1233
+
1234
+ @app.command()
1235
+ def avail(
1236
+ config_name: Optional[str] = typer.Option(
1237
+ None, "--config", "-c", help="Configuration name. Uses active configuration if not specified."
1238
+ ),
1239
+ ):
1240
+ """
1241
+ Show a table of all available default projects and their status in the configuration.
1242
+
1243
+ Projects are marked as:
1244
+ - ✓ Active: Project is in the current configuration
1245
+ - ○ Available: Project can be added to the configuration
1246
+
1247
+ Examples:
1248
+ esgvoc config avail
1249
+ esgvoc config avail --config my_config
1250
+ """
1251
+ service = get_service()
1252
+ config_manager = service.get_config_manager()
1253
+ if config_name is None:
1254
+ config_name = config_manager.get_active_config_name()
1255
+ console.print(f"Showing project availability for: [cyan]{config_name}[/cyan]")
1256
+
1257
+ configs = config_manager.list_configs()
1258
+ if config_name not in configs:
1259
+ console.print(f"[red]Error: Configuration '{config_name}' not found.[/red]")
1260
+ raise typer.Exit(1)
1261
+
1262
+ try:
1263
+ # Load configuration
1264
+ config_path = configs[config_name]
1265
+ config = ServiceSettings.load_from_file(config_path)
1266
+
1267
+ # Get all available default projects
1268
+ available_projects = ServiceSettings._get_default_project_configs()
1269
+
1270
+ table = Table(title=f"Available Projects (Configuration: {config_name})")
1271
+ table.add_column("Status", style="bold")
1272
+ table.add_column("Project Name", style="cyan")
1273
+ table.add_column("Repository", style="green")
1274
+ table.add_column("Branch", style="yellow")
1275
+
1276
+ for project_name, project_config in available_projects.items():
1277
+ # Check if project is in current configuration
1278
+ if config.has_project(project_name):
1279
+ status = "[green]✓ Active[/green]"
1280
+ else:
1281
+ status = "[dim]○ Available[/dim]"
1282
+
1283
+ table.add_row(status, project_name, project_config["github_repo"], project_config["branch"])
1284
+
1285
+ display(table)
1286
+
1287
+ # Show summary
1288
+ active_count = len([p for p in available_projects.keys() if config.has_project(p)])
1289
+ total_count = len(available_projects)
1290
+ console.print(
1291
+ f"\n[blue]Summary: {active_count}/{total_count} projects active in configuration '{config_name}'[/blue]"
1292
+ )
1293
+
1294
+ except Exception as e:
1295
+ console.print(f"[red]Error showing available projects: {str(e)}[/red]")
1296
+ raise typer.Exit(1)
1297
+
1298
+
1299
+ if __name__ == "__main__":
1300
+ app()