climate-ref 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. climate_ref/__init__.py +30 -0
  2. climate_ref/_config_helpers.py +214 -0
  3. climate_ref/alembic.ini +114 -0
  4. climate_ref/cli/__init__.py +138 -0
  5. climate_ref/cli/_utils.py +68 -0
  6. climate_ref/cli/config.py +28 -0
  7. climate_ref/cli/datasets.py +205 -0
  8. climate_ref/cli/executions.py +201 -0
  9. climate_ref/cli/providers.py +84 -0
  10. climate_ref/cli/solve.py +23 -0
  11. climate_ref/config.py +475 -0
  12. climate_ref/constants.py +8 -0
  13. climate_ref/database.py +223 -0
  14. climate_ref/dataset_registry/obs4ref_reference.txt +2 -0
  15. climate_ref/dataset_registry/sample_data.txt +60 -0
  16. climate_ref/datasets/__init__.py +40 -0
  17. climate_ref/datasets/base.py +214 -0
  18. climate_ref/datasets/cmip6.py +202 -0
  19. climate_ref/datasets/obs4mips.py +224 -0
  20. climate_ref/datasets/pmp_climatology.py +15 -0
  21. climate_ref/datasets/utils.py +16 -0
  22. climate_ref/executor/__init__.py +274 -0
  23. climate_ref/executor/local.py +89 -0
  24. climate_ref/migrations/README +22 -0
  25. climate_ref/migrations/env.py +139 -0
  26. climate_ref/migrations/script.py.mako +26 -0
  27. climate_ref/migrations/versions/2025-05-02T1418_341a4aa2551e_regenerate.py +292 -0
  28. climate_ref/models/__init__.py +33 -0
  29. climate_ref/models/base.py +42 -0
  30. climate_ref/models/dataset.py +206 -0
  31. climate_ref/models/diagnostic.py +61 -0
  32. climate_ref/models/execution.py +306 -0
  33. climate_ref/models/metric_value.py +195 -0
  34. climate_ref/models/provider.py +39 -0
  35. climate_ref/provider_registry.py +146 -0
  36. climate_ref/py.typed +0 -0
  37. climate_ref/solver.py +395 -0
  38. climate_ref/testing.py +109 -0
  39. climate_ref-0.5.0.dist-info/METADATA +97 -0
  40. climate_ref-0.5.0.dist-info/RECORD +44 -0
  41. climate_ref-0.5.0.dist-info/WHEEL +4 -0
  42. climate_ref-0.5.0.dist-info/entry_points.txt +2 -0
  43. climate_ref-0.5.0.dist-info/licenses/LICENCE +201 -0
  44. climate_ref-0.5.0.dist-info/licenses/NOTICE +3 -0
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import traceback
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ import xarray as xr
10
+ from ecgtools import Builder
11
+ from loguru import logger
12
+
13
+ from climate_ref.datasets.base import DatasetAdapter
14
+ from climate_ref.datasets.cmip6 import _parse_datetime
15
+ from climate_ref.models.dataset import Dataset, Obs4MIPsDataset
16
+
17
+
18
+ def extract_attr_with_regex(
19
+ input_str: str, regex: str, strip_chars: str | None, ignore_case: bool
20
+ ) -> list[Any] | None:
21
+ """
22
+ Extract version information from attribute with regular expressions.
23
+ """
24
+ if ignore_case:
25
+ pattern = re.compile(regex, re.IGNORECASE)
26
+ else:
27
+ pattern = re.compile(regex)
28
+ match = re.findall(pattern, input_str)
29
+ if match:
30
+ matchstr = max(match, key=len)
31
+ match = matchstr.strip(strip_chars) if strip_chars else matchstr.strip()
32
+ return match
33
+ else:
34
+ return None
35
+
36
+
37
+ def parse_obs4mips(file: str) -> dict[str, Any | None]:
38
+ """Parser for obs4mips"""
39
+ keys = sorted(
40
+ list(
41
+ {
42
+ "activity_id",
43
+ "frequency",
44
+ "grid",
45
+ "grid_label",
46
+ "institution_id",
47
+ "nominal_resolution",
48
+ "realm",
49
+ "product",
50
+ "source_id",
51
+ "source_type",
52
+ "variable_id",
53
+ "variant_label",
54
+ }
55
+ )
56
+ )
57
+
58
+ try:
59
+ time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
60
+ with xr.open_dataset(file, chunks={}, decode_times=time_coder) as ds:
61
+ has_none_value = any(ds.attrs.get(key) is None for key in keys)
62
+ if has_none_value:
63
+ missing_fields = [key for key in keys if ds.attrs.get(key) is None]
64
+ traceback_message = str(missing_fields) + " are missing from the file metadata"
65
+ raise AttributeError(traceback_message)
66
+ info = {key: ds.attrs.get(key) for key in keys}
67
+
68
+ if info["activity_id"] != "obs4MIPs":
69
+ traceback_message = f"{file} is not an obs4MIPs dataset"
70
+ raise TypeError(traceback_message)
71
+
72
+ variable_id = info["variable_id"]
73
+
74
+ if variable_id:
75
+ attrs = ds[variable_id].attrs
76
+ for attr in ["long_name", "units"]:
77
+ info[attr] = attrs.get(attr)
78
+
79
+ # Set the default of # of vertical levels to 1
80
+ vertical_levels = 1
81
+ start_time, end_time = None, None
82
+ try:
83
+ vertical_levels = ds[ds.cf["vertical"].name].size
84
+ except (KeyError, AttributeError, ValueError):
85
+ ...
86
+ try:
87
+ start_time, end_time = str(ds.cf["T"][0].data), str(ds.cf["T"][-1].data)
88
+ except (KeyError, AttributeError, ValueError):
89
+ ...
90
+
91
+ info["vertical_levels"] = vertical_levels
92
+ info["start_time"] = start_time
93
+ info["end_time"] = end_time
94
+ if not (start_time and end_time):
95
+ info["time_range"] = None
96
+ else:
97
+ info["time_range"] = f"{start_time}-{end_time}"
98
+ info["path"] = str(file)
99
+ info["source_version_number"] = (
100
+ extract_attr_with_regex(
101
+ str(file), regex=r"v\d{4}\d{2}\d{2}|v\d{1}", strip_chars=None, ignore_case=True
102
+ )
103
+ or "v0"
104
+ )
105
+ return info
106
+
107
+ except (TypeError, AttributeError) as err:
108
+ if (len(err.args)) == 1:
109
+ logger.warning(str(err.args[0]))
110
+ else:
111
+ logger.warning(str(err.args))
112
+ return {"INVALID_ASSET": file, "TRACEBACK": traceback_message}
113
+ except Exception:
114
+ logger.warning(traceback.format_exc())
115
+ return {"INVALID_ASSET": file, "TRACEBACK": traceback.format_exc()}
116
+
117
+
118
+ class Obs4MIPsDatasetAdapter(DatasetAdapter):
119
+ """
120
+ Adapter for obs4MIPs datasets
121
+ """
122
+
123
+ dataset_cls: type[Dataset] = Obs4MIPsDataset
124
+ slug_column = "instance_id"
125
+
126
+ dataset_specific_metadata = (
127
+ "activity_id",
128
+ "frequency",
129
+ "grid",
130
+ "grid_label",
131
+ "institution_id",
132
+ "nominal_resolution",
133
+ "product",
134
+ "realm",
135
+ "source_id",
136
+ "source_type",
137
+ "variable_id",
138
+ "variant_label",
139
+ "long_name",
140
+ "units",
141
+ "vertical_levels",
142
+ "source_version_number",
143
+ slug_column,
144
+ )
145
+
146
+ file_specific_metadata = ("start_time", "end_time", "path")
147
+
148
+ def __init__(self, n_jobs: int = 1):
149
+ self.n_jobs = n_jobs
150
+
151
+ def pretty_subset(self, data_catalog: pd.DataFrame) -> pd.DataFrame:
152
+ """
153
+ Get a subset of the data_catalog to pretty print
154
+
155
+ This is particularly useful for obs4MIPs datasets, which have a lot of metadata columns.
156
+
157
+ Parameters
158
+ ----------
159
+ data_catalog
160
+ Data catalog to subset
161
+
162
+ Returns
163
+ -------
164
+ :
165
+ Subset of the data catalog to pretty print
166
+
167
+ """
168
+ return data_catalog[
169
+ [
170
+ "activity_id",
171
+ "institution_id",
172
+ "source_id",
173
+ "variable_id",
174
+ "grid_label",
175
+ "source_version_number",
176
+ ]
177
+ ]
178
+
179
+ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame:
180
+ """
181
+ Generate a data catalog from the specified file or directory
182
+
183
+ Each dataset may contain multiple files, which are represented as rows in the data catalog.
184
+ Each dataset has a unique identifier, which is in `slug_column`.
185
+
186
+ Parameters
187
+ ----------
188
+ file_or_directory
189
+ File or directory containing the datasets
190
+
191
+ Returns
192
+ -------
193
+ :
194
+ Data catalog containing the metadata for the dataset
195
+ """
196
+ builder = Builder(
197
+ paths=[str(file_or_directory)],
198
+ depth=10,
199
+ include_patterns=["*.nc"],
200
+ joblib_parallel_kwargs={"n_jobs": self.n_jobs},
201
+ ).build(parsing_func=parse_obs4mips) # type: ignore[arg-type]
202
+
203
+ datasets = builder.df
204
+ if datasets.empty:
205
+ logger.error("No datasets found")
206
+ raise ValueError("No obs4MIPs-compliant datasets found")
207
+
208
+ # Convert the start_time and end_time columns to datetime objects
209
+ # We don't know the calendar used in the dataset (TODO: Check what ecgtools does)
210
+ datasets["start_time"] = _parse_datetime(datasets["start_time"])
211
+ datasets["end_time"] = _parse_datetime(datasets["end_time"])
212
+
213
+ drs_items = [
214
+ "activity_id",
215
+ "institution_id",
216
+ "source_id",
217
+ "variable_id",
218
+ "grid_label",
219
+ "source_version_number",
220
+ ]
221
+ datasets["instance_id"] = datasets.apply(
222
+ lambda row: "obs4MIPs." + ".".join([row[item] for item in drs_items]), axis=1
223
+ )
224
+ return datasets
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter
4
+ from climate_ref.models.dataset import PMPClimatologyDataset
5
+
6
+
7
+ class PMPClimatologyDatasetAdapter(Obs4MIPsDatasetAdapter):
8
+ """
9
+ Adapter for climatology datasets post-processed from obs4MIPs datasets by PMP.
10
+
11
+ These data look like obs4MIPs datasets and are ingested in the same way, but
12
+ are treated separately as they may have the same metadata as the obs4MIPs datasets.
13
+ """
14
+
15
+ dataset_cls = PMPClimatologyDataset
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def validate_path(raw_path: str) -> Path:
5
+ """
6
+ Validate the prefix of a dataset against the data directory
7
+ """
8
+ prefix = Path(raw_path)
9
+
10
+ if not prefix.exists():
11
+ raise FileNotFoundError(prefix)
12
+
13
+ if not prefix.is_absolute():
14
+ raise ValueError(f"Path {prefix} must be absolute")
15
+
16
+ return prefix
@@ -0,0 +1,274 @@
1
+ """
2
+ Execute diagnostics in different environments
3
+
4
+ We support running diagnostics in different environments, such as locally,
5
+ in a separate process, or in a container.
6
+ These environments are represented by `climate_ref.executor.Executor` classes.
7
+
8
+ The simplest executor is the `LocalExecutor`, which runs the diagnostic in the same process.
9
+ This is useful for local testing and debugging.
10
+ """
11
+
12
+ import importlib
13
+ import pathlib
14
+ import shutil
15
+ from typing import TYPE_CHECKING
16
+
17
+ from loguru import logger
18
+ from sqlalchemy import insert
19
+
20
+ from climate_ref.database import Database
21
+ from climate_ref.models.execution import Execution, ExecutionOutput, ResultOutputType
22
+ from climate_ref.models.metric_value import MetricValue
23
+ from climate_ref_core.diagnostics import ExecutionResult, ensure_relative_path
24
+ from climate_ref_core.exceptions import InvalidExecutorException, ResultValidationError
25
+ from climate_ref_core.executor import EXECUTION_LOG_FILENAME, Executor
26
+ from climate_ref_core.pycmec.controlled_vocabulary import CV
27
+ from climate_ref_core.pycmec.metric import CMECMetric
28
+ from climate_ref_core.pycmec.output import CMECOutput, OutputDict
29
+
30
+ if TYPE_CHECKING:
31
+ from climate_ref.config import Config
32
+
33
+
34
+ def import_executor_cls(fqn: str) -> type[Executor]:
35
+ """
36
+ Import an executor using a fully qualified module path
37
+
38
+ Parameters
39
+ ----------
40
+ fqn
41
+ Full package and attribute name of the executor to import
42
+
43
+ For example: `climate_ref_example.executor` will use the `executor` attribute from the
44
+ `climate_ref_example` package.
45
+
46
+ Raises
47
+ ------
48
+ climate_ref_core.exceptions.InvalidExecutorException
49
+ If the executor cannot be imported
50
+
51
+ If the executor isn't a valid `DiagnosticProvider`.
52
+
53
+ Returns
54
+ -------
55
+ :
56
+ Executor instance
57
+ """
58
+ module, attribute_name = fqn.rsplit(".", 1)
59
+
60
+ try:
61
+ imp = importlib.import_module(module)
62
+ executor: type[Executor] = getattr(imp, attribute_name)
63
+
64
+ # We can't really check if the executor is a subclass of Executor here
65
+ # Protocols can't be used with issubclass if they have non-method members
66
+ # We have to check this at class instantiation time
67
+
68
+ return executor
69
+ except ModuleNotFoundError:
70
+ logger.error(f"Package '{fqn}' not found")
71
+ raise InvalidExecutorException(fqn, f"Module '{module}' not found")
72
+ except AttributeError:
73
+ logger.error(f"Provider '{fqn}' not found")
74
+ raise InvalidExecutorException(fqn, f"Executor '{attribute_name}' not found in {module}")
75
+
76
+
77
+ def _copy_file_to_results(
78
+ scratch_directory: pathlib.Path,
79
+ results_directory: pathlib.Path,
80
+ fragment: pathlib.Path | str,
81
+ filename: pathlib.Path | str,
82
+ ) -> None:
83
+ """
84
+ Copy a file from the scratch directory to the executions directory
85
+
86
+ Parameters
87
+ ----------
88
+ scratch_directory
89
+ The directory where the file is currently located
90
+ results_directory
91
+ The directory where the file should be copied to
92
+ fragment
93
+ The fragment of the executions directory where the file should be copied
94
+ filename
95
+ The name of the file to be copied
96
+ """
97
+ assert results_directory != scratch_directory # noqa
98
+ input_directory = scratch_directory / fragment
99
+ output_directory = results_directory / fragment
100
+
101
+ filename = ensure_relative_path(filename, input_directory)
102
+
103
+ if not (input_directory / filename).exists():
104
+ raise FileNotFoundError(f"Could not find {filename} in {input_directory}")
105
+
106
+ output_filename = output_directory / filename
107
+ output_filename.parent.mkdir(parents=True, exist_ok=True)
108
+
109
+ shutil.copy(input_directory / filename, output_filename)
110
+
111
+
112
+ def handle_execution_result(
113
+ config: "Config",
114
+ database: Database,
115
+ execution: Execution,
116
+ result: "ExecutionResult",
117
+ ) -> None:
118
+ """
119
+ Handle the result of a diagnostic execution
120
+
121
+ This will update the diagnostic execution result with the output of the diagnostic execution.
122
+ The output will be copied from the scratch directory to the executions directory.
123
+
124
+ Parameters
125
+ ----------
126
+ config
127
+ The configuration to use
128
+ database
129
+ The active database session to use
130
+ execution
131
+ The diagnostic execution result DB object to update
132
+ result
133
+ The result of the diagnostic execution, either successful or failed
134
+ """
135
+ # Always copy log data
136
+ _copy_file_to_results(
137
+ config.paths.scratch,
138
+ config.paths.results,
139
+ execution.output_fragment,
140
+ EXECUTION_LOG_FILENAME,
141
+ )
142
+
143
+ if result.successful and result.metric_bundle_filename is not None:
144
+ logger.info(f"{execution} successful")
145
+
146
+ _copy_file_to_results(
147
+ config.paths.scratch,
148
+ config.paths.results,
149
+ execution.output_fragment,
150
+ result.metric_bundle_filename,
151
+ )
152
+ execution.mark_successful(result.as_relative_path(result.metric_bundle_filename))
153
+
154
+ if result.output_bundle_filename:
155
+ _copy_file_to_results(
156
+ config.paths.scratch,
157
+ config.paths.results,
158
+ execution.output_fragment,
159
+ result.output_bundle_filename,
160
+ )
161
+ _handle_output_bundle(
162
+ config,
163
+ database,
164
+ execution,
165
+ result.to_output_path(result.output_bundle_filename),
166
+ )
167
+
168
+ cmec_metric_bundle = CMECMetric.load_from_json(result.to_output_path(result.metric_bundle_filename))
169
+
170
+ # Check that the diagnostic values conform with the controlled vocabulary
171
+ try:
172
+ cv = CV.load_from_file(config.paths.dimensions_cv)
173
+ cv.validate_metrics(cmec_metric_bundle)
174
+ except (ResultValidationError, AssertionError):
175
+ logger.exception("Diagnostic values do not conform with the controlled vocabulary")
176
+ # TODO: Mark the diagnostic execution result as failed once the CV has stabilised
177
+ # execution.mark_failed()
178
+
179
+ # Perform a bulk insert of a diagnostic bundle
180
+ # TODO: The section below will likely fail until we have agreed on a controlled vocabulary
181
+ # The current implementation will swallow the exception, but display a log message
182
+ try:
183
+ # Perform this in a nested transaction to (hopefully) gracefully rollback if something
184
+ # goes wrong
185
+ with database.session.begin_nested():
186
+ database.session.execute(
187
+ insert(MetricValue),
188
+ [
189
+ {
190
+ "execution_id": execution.id,
191
+ "value": result.value,
192
+ "attributes": result.attributes,
193
+ **result.dimensions,
194
+ }
195
+ for result in cmec_metric_bundle.iter_results()
196
+ ],
197
+ )
198
+ except Exception:
199
+ # TODO: Remove once we have settled on a controlled vocabulary
200
+ logger.exception("Something went wrong when ingesting diagnostic values")
201
+
202
+ # TODO: This should check if the result is the most recent for the execution,
203
+ # if so then update the dirty fields
204
+ # i.e. if there are outstanding executions don't make as clean
205
+ execution.execution_group.dirty = False
206
+ else:
207
+ logger.error(f"{execution} failed")
208
+ execution.mark_failed()
209
+
210
+
211
+ def _handle_output_bundle(
212
+ config: "Config",
213
+ database: Database,
214
+ execution: Execution,
215
+ cmec_output_bundle_filename: pathlib.Path,
216
+ ) -> None:
217
+ # Extract the registered outputs
218
+ # Copy the content to the output directory
219
+ # Track in the db
220
+ cmec_output_bundle = CMECOutput.load_from_json(cmec_output_bundle_filename)
221
+ _handle_outputs(
222
+ cmec_output_bundle.plots,
223
+ output_type=ResultOutputType.Plot,
224
+ config=config,
225
+ database=database,
226
+ execution=execution,
227
+ )
228
+ _handle_outputs(
229
+ cmec_output_bundle.data,
230
+ output_type=ResultOutputType.Data,
231
+ config=config,
232
+ database=database,
233
+ execution=execution,
234
+ )
235
+ _handle_outputs(
236
+ cmec_output_bundle.html,
237
+ output_type=ResultOutputType.HTML,
238
+ config=config,
239
+ database=database,
240
+ execution=execution,
241
+ )
242
+
243
+
244
+ def _handle_outputs(
245
+ outputs: dict[str, OutputDict] | None,
246
+ output_type: ResultOutputType,
247
+ config: "Config",
248
+ database: Database,
249
+ execution: Execution,
250
+ ) -> None:
251
+ if outputs is None:
252
+ return
253
+
254
+ for key, output_info in outputs.items():
255
+ filename = ensure_relative_path(
256
+ output_info.filename, config.paths.scratch / execution.output_fragment
257
+ )
258
+
259
+ _copy_file_to_results(
260
+ config.paths.scratch,
261
+ config.paths.results,
262
+ execution.output_fragment,
263
+ filename,
264
+ )
265
+ database.session.add(
266
+ ExecutionOutput(
267
+ execution_id=execution.id,
268
+ output_type=output_type,
269
+ filename=str(filename),
270
+ description=output_info.description,
271
+ short_name=key,
272
+ long_name=output_info.long_name,
273
+ )
274
+ )
@@ -0,0 +1,89 @@
1
+ from typing import Any
2
+
3
+ from loguru import logger
4
+
5
+ from climate_ref.config import Config
6
+ from climate_ref.database import Database
7
+ from climate_ref.executor import handle_execution_result
8
+ from climate_ref.models import Execution
9
+ from climate_ref_core.diagnostics import Diagnostic, ExecutionDefinition, ExecutionResult
10
+ from climate_ref_core.logging import redirect_logs
11
+ from climate_ref_core.providers import DiagnosticProvider
12
+
13
+
14
+ class LocalExecutor:
15
+ """
16
+ Run a diagnostic locally, in-process.
17
+
18
+ This is mainly useful for debugging and testing.
19
+ The production executor will run the diagnostic in a separate process or container,
20
+ the exact manner of which is yet to be determined.
21
+ """
22
+
23
+ name = "local"
24
+
25
+ def __init__(
26
+ self, *, database: Database | None = None, config: Config | None = None, **kwargs: Any
27
+ ) -> None:
28
+ if config is None:
29
+ config = Config.default()
30
+ if database is None:
31
+ database = Database.from_config(config, run_migrations=False)
32
+
33
+ self.database = database
34
+ self.config = config
35
+
36
+ def run(
37
+ self,
38
+ provider: DiagnosticProvider,
39
+ diagnostic: Diagnostic,
40
+ definition: ExecutionDefinition,
41
+ execution: Execution | None = None,
42
+ ) -> None:
43
+ """
44
+ Run a diagnostic in process
45
+
46
+ Parameters
47
+ ----------
48
+ provider
49
+ The provider of the diagnostic
50
+ diagnostic
51
+ Diagnostic to run
52
+ definition
53
+ A description of the information needed for this execution of the diagnostic
54
+ execution
55
+ A database model representing the execution of the diagnostic.
56
+ If provided, the result will be updated in the database when completed.
57
+ """
58
+ definition.output_directory.mkdir(parents=True, exist_ok=True)
59
+
60
+ try:
61
+ with redirect_logs(definition, self.config.log_level):
62
+ result = diagnostic.run(definition=definition)
63
+ except Exception:
64
+ if execution is not None: # pragma: no branch
65
+ info_msg = (
66
+ f"\nAdditional information about this execution can be viewed using: "
67
+ f"ref executions inspect {execution.execution_group_id}"
68
+ )
69
+ else:
70
+ info_msg = ""
71
+
72
+ logger.exception(f"Error running diagnostic {diagnostic.slug}. {info_msg}")
73
+ result = ExecutionResult.build_from_failure(definition)
74
+
75
+ if execution:
76
+ handle_execution_result(self.config, self.database, execution, result)
77
+
78
+ def join(self, timeout: float) -> None:
79
+ """
80
+ Wait for all diagnostics to finish
81
+
82
+ This returns immediately because the local executor runs diagnostics synchronously.
83
+
84
+ Parameters
85
+ ----------
86
+ timeout
87
+ Timeout in seconds (Not used)
88
+ """
89
+ return
@@ -0,0 +1,22 @@
1
+ # Alembic
2
+
3
+ Alembic is a database migration tool.
4
+ It interoperates with SqlAlchemy to determine how the currently declared models differ from what the database
5
+ expects and generates a migration to apply the changes.
6
+
7
+ The migrations are applied at run-time automatically (see [ref.database.Database]()).
8
+
9
+ ## Generating migrations
10
+
11
+ To generate a migration,
12
+ you can use the `uv run` command with the `alembic` package and the `revision` command.
13
+ The `--rev-id` flag is used to specify the revision id.
14
+ If it is omitted the revision id will be generated automatically.
15
+
16
+ ```
17
+ uv run --package ref alembic revision --rev-id 0.1.0 --message "initial table" --autogenerate
18
+ ```
19
+
20
+ How we name and manage these migrations is still a work in progress.
21
+ It might be nice to have a way to automatically generate the revision id based on the version of the package.
22
+ This would allow us to easily track which migrations have been applied to the database.