diff --git a/docs/cli_usage.md b/docs/cli_usage.md index 591dbe058..f5796c68c 100644 --- a/docs/cli_usage.md +++ b/docs/cli_usage.md @@ -220,7 +220,10 @@ For each command / subcommand you can provide `--help` argument to get information about usage. ```{eval-rst} -.. click:: mdio.__main__:main +.. typer:: mdio.cli:app :prog: mdio - :nested: full + :theme: monokai + :width: 100 + :show-nested: + :make-sections: ``` diff --git a/docs/conf.py b/docs/conf.py index aeb59636a..7469865c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,8 +18,8 @@ "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinxcontrib.autodoc_pydantic", + "sphinxcontrib.typer", "sphinx.ext.autosectionlabel", - "sphinx_click", "sphinx_copybutton", "myst_nb", "sphinx_design", diff --git a/docs/requirements.txt b/docs/requirements.txt index 53c0cebd7..4c41d2e35 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,7 +5,7 @@ linkify-it-py==2.0.3 matplotlib==3.10.6 myst-nb==1.3.0 sphinx==8.2.3 -sphinx-click==6.1.0 +sphinxcontrib-typer==0.6.2 sphinx-copybutton==0.5.2 sphinx-design==0.6.1 ipywidgets==8.1.7 diff --git a/noxfile.py b/noxfile.py index 64e715e4b..a20d4eb6c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -232,7 +232,7 @@ def docs_build(session: Session) -> None: "matplotlib", "myst-nb", "sphinx", - "sphinx-click", + "sphinxcontrib-typer", "sphinx-copybutton", "sphinx-design", "ipywidgets", @@ -261,8 +261,8 @@ def docs(session: Session) -> None: "matplotlib", "myst-nb", "sphinx", + "sphinxcontrib-typer", "sphinx-autobuild", - "sphinx-click", "sphinx-copybutton", "sphinx-design", "ipywidgets", diff --git a/pyproject.toml b/pyproject.toml index 518eb00d8..6a7781bbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,14 +18,13 @@ classifiers = [ ] dependencies = [ - "click>=8.3.0", - "click-params>=0.5.0", + "typer>=0.20.0", "dask>=2025.9.1", "fsspec>=2025.9.0", "pint>=0.25.0", "psutil>=7.1.0", "pydantic>=2.12.0", - "rich>=14.1.0", + "questionary>=2.1.1", "segy>=0.5.3", "tqdm>=4.67.1", "universal-pathlib>=0.3.3", @@ -44,7 +43,7 @@ repository = "https://github.com/TGSAI/mdio-python" documentation = "https://mdio-python.readthedocs.io" [project.scripts] -mdio = "mdio.__main__:main" +mdio = "mdio.cli:app" [dependency-groups] dev = [ @@ -68,8 +67,8 @@ docs = [ "matplotlib>=3.10.6", "myst-nb>=1.3.0", "sphinx>=8.2.3", + "sphinxcontrib-typer>=0.6.2", "sphinx-autobuild>=2025.8.25", - "sphinx-click>=6.1.0", "sphinx-copybutton>=0.5.2", "sphinx-design>=0.6.1", "ipywidgets>=8.1.7", @@ -123,9 +122,10 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101", "PLR2004"] -"tests/integration/test_segy_import_export_masked.py" = ["E501"] -"docs/tutorials/*.ipynb" = ["S101"] +"tests/*" = ["S101", "PLR2004"] # allow assertions and magic value comparison +"tests/integration/test_segy_import_export_masked.py" = ["E501"] # allow long lines for readibility +"docs/tutorials/*.ipynb" = ["S101"] # allow assertions +"src/mdio/commands/*.py" = ["PLC0415", "D301"] # allow delayed imports and \b without raw strings [tool.ruff.lint.flake8-annotations] mypy-init-return = true @@ -145,6 +145,7 @@ style = "google" arg-type-hints-in-docstring = false check-return-types = false check-yield-types = false +exclude = "src/mdio/commands/segy.py" [tool.coverage.paths] source = ["src", "*/site-packages"] diff --git a/src/mdio/__main__.py b/src/mdio/__main__.py deleted file mode 100644 index 1d971bf40..000000000 --- a/src/mdio/__main__.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Command-line interface.""" - -from __future__ import annotations - -import importlib -from importlib import metadata -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable - from typing import Any - -import click - -KNOWN_MODULES = ["segy.py", "copy.py", "info.py"] - - -class MyCLI(click.Group): - """CLI generator via plugin design pattern. - - This class dynamically loads command modules from the specified `plugin_folder`. If the - command us another CLI group, the command module must define a `cli = click.Group(...)` and - subsequent commands must be added to this CLI. If it is a single utility it must have a - variable named `cli` for the command to be exposed. - - Args: - plugin_folder: Path to the directory containing command modules - *args: Variable length argument list passed to the click.Group. - **kwargs: Arbitrary keyword arguments passed to the click.Group. - """ - - def __init__(self, plugin_folder: Path, *args: Any, **kwargs: Any): # noqa: ANN401 - super().__init__(*args, **kwargs) - self.plugin_folder = plugin_folder - self.known_modules = KNOWN_MODULES - - def list_commands(self, _ctx: click.Context) -> list[str]: - """List commands available under `commands` module.""" - rv = [] - for filename in self.plugin_folder.iterdir(): - is_known = filename.name in self.known_modules - is_python = filename.suffix == ".py" - if is_known and is_python: - rv.append(filename.stem) - rv.sort() - return rv - - def get_command(self, _ctx: click.Context, name: str) -> Callable | None: - """Get command implementation from `commands` module.""" - try: - filepath = self.plugin_folder / f"{name}.py" - if filepath.name not in self.known_modules: - click.echo(f"Command {name} is not safe to execute.") - return None - - module_name = f"mdio.commands.{name}" - spec = importlib.util.spec_from_file_location(module_name, str(filepath)) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.cli - except Exception as e: - click.echo(f"Error loading command {name}: {e}") - return None - - -def get_package_version(package_name: str, default: str = "unknown") -> str: - """Safely fetch the package version, providing a default if not found.""" - try: - return metadata.version(package_name) - except metadata.PackageNotFoundError: - return default - - -@click.command(cls=MyCLI, plugin_folder=Path(__file__).parent / "commands") -@click.version_option(get_package_version("multidimio")) -def main() -> None: - """Welcome to MDIO! - - MDIO is an open source, cloud-native, and scalable storage engine - for various types of energy data. - - MDIO supports importing or exporting various data containers, - hence we allow plugins as subcommands. - - From this main command, we can see the MDIO version. - """ diff --git a/src/mdio/cli.py b/src/mdio/cli.py new file mode 100644 index 000000000..345c77a1e --- /dev/null +++ b/src/mdio/cli.py @@ -0,0 +1,10 @@ +"""Entrypoint to the MDIO command line interface (CLI).""" + +import typer + +from mdio.commands import segy +from mdio.commands import version + +app = typer.Typer(no_args_is_help=True) +app.add_typer(segy.app, name="segy") +app.add_typer(version.app) diff --git a/src/mdio/commands/segy.py b/src/mdio/commands/segy.py index e234d9e78..cf91ec8f2 100644 --- a/src/mdio/commands/segy.py +++ b/src/mdio/commands/segy.py @@ -1,389 +1,574 @@ -"""SEG-Y Import/Export CLI Plugin.""" +"""SEG-Y CLI subcommands for importing from SEG-Y to MDIO and (future) exporting back. +This sub-app is available under the main CLI as: mdio segy . +Run: mdio segy --help or mdio segy import --help for usage and examples. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from typing import Annotated from typing import Any -from click import BOOL -from click import FLOAT -from click import STRING -from click import Choice -from click import Group -from click import Path -from click import argument -from click import option -from click_params import JSON -from click_params import IntListParamType -from click_params import StringListParamType - -SEGY_HELP = """ -MDIO and SEG-Y conversion utilities. Below is general information about the SEG-Y format and MDIO -features. For import or export specific functionality check the import or export modules: - -\b -mdio segy import --help -mdio segy export --help - -MDIO can import SEG-Y files to a modern, chunked format. - -The SEG-Y format is defined by the Society of Exploration Geophysicists as a data transmission -format and has its roots back to 1970s. There are currently multiple revisions of the SEG-Y format. - -MDIO can unravel and index any SEG-Y file that is on a regular grid. There is no limitation to -dimensionality of the data, as long as it can be represented on a regular grid. Most seismic -surveys are on a regular grid of unique shot/receiver IDs or are imaged on regular CDP or -INLINE/CROSSLINE grids. - -The SEG-Y headers are used as identifiers to take the flattened SEG-Y data and convert it to the -multi-dimensional tensor representation. An example of ingesting a 3-D Post-Stack seismic data can -be though as the following, per the SEG-Y Rev1 standard: - -\b ---header-names inline,crossline ---header-locations 189,193 ---header-types int32,int32 - -\b -Our recommended chunk sizes are: -(Based on GCS benchmarks) -\b -3D: 64 x 64 x 64 -2D: 512 x 512 - -The 4D+ datasets chunking recommendation depends on the type of 4D+ dataset (i.e. SHOT vs CDP data -will have different chunking). - -MDIO also import or export big and little endian coded IBM or IEEE floating point formatted SEG-Y -files. MDIO can also build a grid from arbitrary header locations for indexing. However, the -headers are stored as the SEG-Y Rev 1 after ingestion. -""" +import click +import questionary +import typer +from rich import print # noqa: A004 +from segy.schema.format import TextHeaderEncoding +from segy.schema.segy import SegyStandard +from upath import UPath + +from mdio.converters.exceptions import GridTraceSparsityError +from mdio.exceptions import MDIOMissingFieldError + +if TYPE_CHECKING: + from click.core import Context + from click.core import Parameter + from segy.schema.header import HeaderField + from segy.schema.segy import SegySpec + + from mdio.builder.templates.base import AbstractDatasetTemplate + +app = typer.Typer(help="Convert SEG-Y <-> MDIO datasets.") + + +REVISION_MAP = { + "rev 0": SegyStandard.REV0, + "rev 1": SegyStandard.REV1, + "rev 2": SegyStandard.REV2, + "rev 2.1": SegyStandard.REV21, +} + + +class UPathParamType(click.ParamType): + """Click parser for UPath.""" + + name = "Path" + + def convert(self, value: str, param: Parameter | None, ctx: Context | None) -> UPath: # noqa: ARG002 + """Convert string path to UPath.""" + try: + return UPath(value) + except Exception: + self.fail(f"{value} can't be initialized as UPath", param, ctx) + + +class JSONParamType(click.ParamType): + """Click parser for JSON.""" -cli = Group(name="segy", help=SEGY_HELP) - - -@cli.command(name="import") -@argument("segy-path", type=STRING) -@argument("mdio-path", type=STRING) -@option( - "-loc", - "--header-locations", - required=True, - help="Byte locations of the index attributes in SEG-Y trace header.", - type=IntListParamType(), -) -@option( - "-types", - "--header-types", - required=False, - help="Data types of the index attributes in SEG-Y trace header.", - type=StringListParamType(), -) -@option( - "-names", - "--header-names", - required=False, - help="Names of the index attributes", - type=StringListParamType(), -) -@option( - "-chunks", - "--chunk-size", - required=False, - help="Custom chunk size for bricked storage", - type=IntListParamType(), -) -@option( - "-lossless", - "--lossless", - required=False, - default=True, - help="Toggle lossless, and perceptually lossless compression", - type=BOOL, - show_default=True, -) -@option( - "-tolerance", - "--compression-tolerance", - required=False, - default=0.01, - help="Lossy compression tolerance in ZFP.", - type=FLOAT, - show_default=True, -) -@option( - "-storage-input", - "--storage-options-input", - required=False, - help="Storage options for SEG-Y input file.", - type=JSON, -) -@option( - "-storage-output", - "--storage-options-output", - required=False, - help="Storage options for the MDIO output file.", - type=JSON, -) -@option( - "-overwrite", - "--overwrite", - is_flag=True, - help="Flag to overwrite the MDIO file if it exists", - show_default=True, -) -@option( - "-grid-overrides", - "--grid-overrides", - required=False, - help="Option to add grid overrides.", - type=JSON, -) + name = "JSON" + + def convert(self, value: str, param: Parameter | None, ctx: Context | None) -> dict[str, Any]: # noqa: ARG002 + """Convert JSON-like string to dict.""" + try: + return json.loads(value) + except json.JSONDecodeError: + self.fail(f"{value} is not a valid json string", param, ctx) + + +def prompt_for_segy_standard() -> SegyStandard: + """Prompt user to select a SEG-Y standard.""" + choices = list(REVISION_MAP.keys()) + standard_str = questionary.select("Select SEG-Y standard:", choices=choices, default="rev 1").ask() + return SegyStandard(REVISION_MAP[standard_str]) + + +def prompt_for_text_encoding() -> str: + """Prompt user for text header encoding.""" + return questionary.select("Select text header encoding:", choices=["ebcdic", "ascii"], default="ebcdic").ask() + + +def prompt_for_header_fields(field_type: str, segy_spec: SegySpec | None = None) -> list[HeaderField]: + """Prompt user to customize header fields with interactive choices. + + Improvements over the previous version: + - No comma-separated input is required. + - You can pick known fields from the current SEG-Y spec via a checkbox. + - You can add new fields using guided prompts for name, byte, and format. + """ + from segy.schema.header import HeaderField + + def _get_known_fields() -> list[HeaderField]: + if segy_spec is None: + return [] + if field_type.lower() == "binary": + return list(getattr(segy_spec.binary.header, "fields", [])) + if field_type.lower() == "trace": + return list(getattr(segy_spec.trace.header, "fields", [])) + return [] + + def _format_choice(hf: HeaderField) -> str: + # Show helpful info for each known field + return f"{hf.name} (byte={hf.byte}, format={hf.format})" + + fields: list[HeaderField] = [] + + if not questionary.confirm(f"Customize {field_type} header fields?", default=False).ask(): + return fields + + while True: + action = questionary.select( + f"Customize {field_type} header fields — choose an action:", + choices=[ + "Add from known fields", + "Add a new field", + "View current selections", + "Clear selections", + "Done", + ], + default="Add a new field", + ).ask() + + if action == "Add from known fields": + known = _get_known_fields() + if not known: + print("No known fields available to select. You can still add a new field.") + continue + choices = [_format_choice(hf) for hf in known] + selected = questionary.checkbox(f"Select {field_type} header fields to add:", choices=choices).ask() or [] + # Map chosen strings back to HeaderField models + lookup = {_format_choice(hf): hf for hf in known} + for label in selected: + hf = lookup.get(label) + if hf is not None: + fields.append(HeaderField.model_validate(hf.model_dump())) + + elif action == "Add a new field": + name = questionary.text("Field name (e.g., inline):").ask() + if not name: + print("Name cannot be empty.") + continue + + byte_str = questionary.text("Starting byte (integer):").ask() + try: + byte_val = int(byte_str) + except (TypeError, ValueError): + print("Byte must be an integer.") + continue + + # Choose data format from available ScalarType values (from segy library) + from segy.schema.format import ScalarType + + fmt_choices = [s.value for s in ScalarType] + format_ = questionary.select("Data format:", choices=fmt_choices, default="int32").ask() + if not format_: + print("Format cannot be empty.") + continue + + try: + valid_field = HeaderField.model_validate({"name": name, "byte": byte_val, "format": format_}) + except Exception as exc: # pydantic validation error + print(f"Invalid field specification: {exc}") + continue + fields.append(valid_field) + + elif action == "View current selections": + if not fields: + print("No custom fields selected yet.") + else: + print("Currently selected fields:") + for i, hf in enumerate(fields, start=1): + print(f" {i}. {hf.name} (byte={hf.byte}, format={hf.format})") + + elif action == "Clear selections": + if fields and questionary.confirm("Clear all selected fields?", default=False).ask(): + fields = [] + + elif action == "Done": + break + + return fields + + +def create_segy_spec( + input_path: UPath, mdio_template: AbstractDatasetTemplate, preselected_encoding: str | None = None +) -> SegySpec: + """Create SEG-Y specification interactively.""" + from segy.standards.registry import get_segy_standard + + # Preview textual header FIRST with EBCDIC by default (before selecting SEG-Y revision) + if preselected_encoding is None: + text_encoding = TextHeaderEncoding.EBCDIC + segy_spec_preview = get_segy_standard(SegyStandard.REV1) + segy_spec_preview.text_header.encoding = text_encoding + if segy_spec_preview.ext_text_header is not None: + segy_spec_preview.ext_text_header.spec.encoding = text_encoding + segy_spec_preview.endianness = None + + if questionary.confirm("Preview textual header now?", default=True).ask(): + include_ext = False + while True: + main_txt, ext_txt_list = _read_text_headers(input_path, segy_spec_preview) + ext_to_show = ext_txt_list if include_ext else [] + bundle = _format_header_bundle(main_txt, ext_to_show, segy_spec_preview.text_header.encoding) + _pager(bundle) + + # If there are extended headers, let the user decide to include them next time + if ext_txt_list: + include_ext = questionary.confirm( + "Include extended text headers in the next preview?", default=include_ext + ).ask() + + # Offer saving the currently displayed content + did_save = False + if questionary.confirm("Save displayed header(s) to a file?", default=False).ask(): + default_hdr_path = input_path.with_name(f"{input_path.stem}_text_header.txt") + out_hdr_uri = questionary.text( + "Filename for textual header:", default=default_hdr_path.as_posix() + ).ask() + if out_hdr_uri: + with UPath(out_hdr_uri).open("w") as fh: + fh.write(bundle) + print(f"Textual header saved to '{out_hdr_uri}'.") + did_save = True + + # If the user saved, exit the preview loop without asking to preview again + if did_save: + break + + # Allow switching encoding and re-previewing + if not questionary.confirm("Switch encoding and preview again?", default=False).ask(): + break + + new_enc = prompt_for_text_encoding() + text_encoding = new_enc or text_encoding + segy_spec_preview.text_header.encoding = text_encoding + if segy_spec_preview.ext_text_header is not None: + segy_spec_preview.ext_text_header.spec.encoding = text_encoding + else: + text_encoding = preselected_encoding + + # Now prompt for SEG-Y standard and build the final spec + segy_standard = prompt_for_segy_standard() + segy_spec = get_segy_standard(segy_standard) + segy_spec.text_header.encoding = text_encoding + if segy_standard >= 1: + segy_spec.ext_text_header.spec.encoding = text_encoding + segy_spec.endianness = None + + # Optionally reduce to only template-required trace headers BEFORE customizations, + # so any user-added extra fields remain intact afterwards. + is_minimal = questionary.confirm("Import only trace headers required by template?", default=False).ask() + if is_minimal: + required_fields = set(mdio_template.coordinate_names) | set(mdio_template.spatial_dimension_names) + required_fields = required_fields | {"coordinate_scalar"} + new_fields = [field for field in segy_spec.trace.header.fields if field.name in required_fields] + segy_spec.trace.header.fields = new_fields + + # Now prompt for any customizations; these will be applied on top of the (possibly minimal) spec. + binary_fields = prompt_for_header_fields("binary", segy_spec) + trace_fields = prompt_for_header_fields("trace", segy_spec) + if binary_fields or trace_fields: + segy_spec = segy_spec.customize(binary_header_fields=binary_fields, trace_header_fields=trace_fields) + + should_save = questionary.confirm("Save SEG-Y specification?", default=True).ask() + if should_save: + from segy import SegyFile + + out_segy_spec_path = input_path.with_name(f"{input_path.stem}_segy_spec.json") + out_segy_spec_uri = out_segy_spec_path.as_posix() + + custom_uri = questionary.text("Filename for SEG-Y Specification:", default=out_segy_spec_uri).ask() + custom_path = UPath(custom_uri) + updated_spec = SegyFile(input_path.as_posix(), spec=segy_spec).spec + + with custom_path.open(mode="w") as f: + json.dump(updated_spec.model_dump(mode="json"), f, indent=2) + print(f"SEG-Y specification saved to '{custom_uri}'.") + + return segy_spec + + +def prompt_for_mdio_template() -> AbstractDatasetTemplate: + """Prompt user to select a MDIO template.""" + from mdio.builder.template_registry import get_template_registry + + registry = get_template_registry() + choices = registry.list_all_templates() + template_name = questionary.select("Select MDIO template:", choices=choices).ask() + + if template_name is None: + raise typer.Abort + + return registry.get(template_name) + + +def load_mdio_template(mdio_template_name: str) -> AbstractDatasetTemplate: + """Load MDIO template from registry or select one interactively.""" + from mdio.builder.template_registry import get_template_registry + + registry = get_template_registry() + try: + return registry.get(mdio_template_name) + except KeyError: + typer.secho(f"MDIO template '{mdio_template_name}' not found.", fg="red", err=True) + raise typer.Abort from None + + +def load_segy_spec(segy_spec_path: UPath) -> SegySpec: + """Load SEG-Y specification from a file.""" + from pydantic import ValidationError + from segy.schema.segy import SegySpec + + try: + with segy_spec_path.open("r") as f: + return SegySpec.model_validate_json(f.read()) + except FileNotFoundError: + typer.secho(f"SEG-Y specification file '{segy_spec_path}' does not exist.", fg="red", err=True) + raise typer.Abort from None + except ValidationError: + typer.secho(f"Invalid SEG-Y specification file '{segy_spec_path}'.", fg="red", err=True) + raise typer.Abort from None + + +SegyOutType = Annotated[UPath, typer.Argument(help="Path to the input SEG-Y file.", click_type=UPathParamType())] +MdioOutType = Annotated[UPath, typer.Argument(help="Path to the output MDIO file.", click_type=UPathParamType())] +MDIOTemplateType = Annotated[str | None, typer.Option(help="Name of the MDIO template.")] +SegySpecType = Annotated[UPath | None, typer.Option(help="Path to the SEG-Y spec file.", click_type=UPathParamType())] +StorageOptionType = Annotated[dict | None, typer.Option(help="Options for remote storage.", click_type=JSONParamType())] +OverwriteType = Annotated[bool, typer.Option(help="Overwrite the MDIO file if it exists.")] +InteractiveType = Annotated[bool, typer.Option(help="Enable interactive prompts when template or spec are missing.")] + + +@app.command(name="import") def segy_import( # noqa: PLR0913 - segy_path: str, - mdio_path: str, - header_locations: list[int], - header_types: list[str], - header_names: list[str], - chunk_size: list[int], - lossless: bool, - compression_tolerance: float, - storage_options_input: dict[str, Any], - storage_options_output: dict[str, Any], - overwrite: bool, - grid_overrides: dict[str, Any], + input_path: SegyOutType, + output_path: MdioOutType, + mdio_template: MDIOTemplateType = None, + segy_spec: SegySpecType = None, + storage_input: StorageOptionType = None, + storage_output: StorageOptionType = None, + overwrite: OverwriteType = False, + interactive: InteractiveType = False, ) -> None: - r"""Ingest SEG-Y file to MDIO. - - SEG-Y format is explained in the "segy" group of the command line interface. To see additional - information run: - - mdio segy --help - - MDIO allows ingesting flattened seismic surveys in SEG-Y format into a multidimensional - tensor that represents the correct geometry of the seismic dataset. - - The output MDIO file can be local or on the cloud. For local files, a UNIX or Windows path is - sufficient. However, for cloud stores, an appropriate protocol must be provided. Some examples: - - File Path Patterns: - - \b - If we are working locally: - --input_segy_path local_seismic.segy - --output-mdio-path local_seismic.mdio - - \b - If we are working on the cloud on Amazon Web Services: - --input_segy_path local_seismic.segy - --output-mdio-path s3://bucket/local_seismic.mdio - - \b - If we are working on the cloud on Google Cloud: - --input_segy_path local_seismic.segy - --output-mdio-path gs://bucket/local_seismic.mdio - - \b - If we are working on the cloud on Microsoft Azure: - --input_segy_path local_seismic.segy - --output-mdio-path abfs://bucket/local_seismic.mdio - - The SEG-Y headers for indexing must also be specified. The index byte locations (starts from 1) - are the minimum amount of information needed to index the file. However, we suggest giving - names to the index dimensions, and if needed providing the header types if they are not - standard. By default, all header entries are assumed to be 4-byte long (int32). - - The chunk size depends on the data type, however, it can be chosen to accommodate any - workflow's access patterns. See examples below for some common use cases. - - By default, the data is ingested with LOSSLESS compression. This saves disk space in the range - of 20% to 40%. MDIO also allows data to be compressed using the ZFP compressor's fixed - accuracy lossy compression. If lossless parameter is set to False and MDIO was installed using - the lossy extra; then the data will be compressed to approximately 30% of its original size and - will be perceptually lossless. The compression amount can be adjusted using the option - compression_tolerance (float). Values less than 1 gives good results. The higher the value, the - more compression, but will introduce artifacts. The default value is 0.01 tolerance, however we - get good results up to 0.5; where data is almost compressed to 10% of its original size. NOTE: - This assumes data has amplitudes normalized to have approximately standard deviation of 1. If - dataset has values smaller than this tolerance, a lot of loss may occur. - - Usage: - - Below are some examples of ingesting standard SEG-Y files per the SEG-Y Revision 1 and 2. - - \b - 3D Seismic Post-Stack: - Chunks: 128 inlines x 128 crosslines x 128 samples - --header-locations 189,193 - --header-names inline,crossline - - - \b - 3D Seismic Imaged Pre-Stack Gathers: - Chunks: 16 inlines x 16 crosslines x 16 offsets x 512 samples - --header-locations 189,193,37 - --header-names inline,crossline,offset - --chunk-size 16,16,16,512 - - \b - 2D Seismic Shot Data (Byte Locations Vary): - Chunks: 16 shots x 256 channels x 512 samples - --header-locations 9,13 - --header-names shot,chan - --chunk-size 16,256,512 - - \b - 3D Seismic Shot Data (Byte Locations Vary): - Let's assume streamer number is at byte 213 as - a 2-byte integer field. - Chunks: 8 shots x 2 cables x 256 channels x 512 samples - --header-locations 9,213,13 - --header-names shot,cable,chan - --header-types int32,int16,int32 - --chunk-size 8,2,256,512 - - We can override the dataset grid by the `grid_overrides` parameter. This allows us to ingest - files that don't conform to the true geometry of the seismic acquisition. - - For example if we are ingesting 3D seismic shots that don't have a cable number and channel - numbers are sequential (i.e. each cable doesn't start with channel number 1; we can tell MDIO - to ingest this with the correct geometry by calculating cable numbers and wrapped channel - numbers. Note the missing byte location and type for the "cable" index. - - - Usage: - 3D Seismic Shot Data (Byte Locations Vary): - Let's assume streamer number does not exist but there are - 800 channels per cable. - Chunks: 8 shots x 2 cables x 256 channels x 512 samples - --header-locations 9,None,13 - --header-names shot,cable,chan - --header-types int32,None,int32 - --chunk-size 8,2,256,512 - - \b - No grid overrides are necessary for shot gathers with channel numbers and wrapped channels. - - In cases where the user does not know if the input has unwrapped channels but desires to - store with wrapped channel index use: --grid-overrides '{"AutoChannelWrap": True}' - - \b - For cases with no well-defined trace header for indexing a NonBinned grid override is - provided.This creates the index and attributes an incrementing integer to the trace for the - index based on first in first out. For example a CDP and Offset keyed file might have a - header for offset as real world offset which would result in a very sparse populated index. - Instead, the following override will create a new index from 1 to N, where N is the number - of offsets within a CDP ensemble. The index to be auto generated is called "trace". Note - the required "chunksize" parameter in the grid override. This is due to the non-binned - ensemble chunksize is irrelevant to the index dimension chunksizes and has to be specified - in the grid override itself. Note the lack of offset, only indexing CDP, providing CDP - header type, and chunksize for only CDP and Sample dimension. The chunksize for non-binned - dimension is in grid overrides. Below configuration will yield 1MB chunks. - \b - --header-locations 21 - --header-names cdp - --header-types int32 - --chunk-size 4,1024 - --grid-overrides '{"NonBinned": True, "chunksize": 64}' - - \b - A more complicated case where you may have a 5D dataset that is not binned in Offset and - Azimuth directions can be ingested like below. However, the Offset and Azimuth dimensions - will be combined to "trace" dimension. The below configuration will yield 1MB chunks. - \b - --header-locations 189,193 - --header-names inline,crossline - --header-types int32,int32 - --chunk-size 4,4,1024 - --grid-overrides '{"NonBinned": True, "chunksize": 16}' - - \b - For dataset with expected duplicate traces we have the following parameterization. This - will use the same logic as NonBinned with a fixed chunksize of 1. The other keys are still - important. The below example allows multiple traces per receiver (i.e. reshoot). - \b - --header-locations 9,213,13 - --header-names shot,cable,chan - --header-types int32,int16,int32 - --chunk-size 8,2,256,512 - --grid-overrides '{"HasDuplicates": True}' + """Convert a SEG-Y file into an MDIO dataset. + + \b + In non-interactive mode you must provide both --mdio-template and --segy-spec. + Use --interactive to be guided through selecting a template and building a SEG-Y spec. + + \b + Examples: + - Non-interactive (local files): + mdio segy import in.segy out.mdio --mdio-template PostStack3DTime --segy-spec spec.json + - Overwrite existing output with interactive template with spec: + mdio segy import in.segy out.mdio --segy-spec spec.json --overwrite + - Interactive (prompts for template and spec): + mdio segy import in.segy out.mdio --interactive + + \b + Notes: + - Storage options are fsspec-compatible JSON passed to --storage-input/--storage-output. + - The command fails if output exists unless --overwrite is provided. """ - # Lazy import to reduce CLI startup time - from mdio import segy_to_mdio # noqa: PLC0415 - - segy_to_mdio( - segy_path=segy_path, - mdio_path_or_buffer=mdio_path, - index_bytes=header_locations, - index_types=header_types, - index_names=header_names, - chunksize=chunk_size, - lossless=lossless, - compression_tolerance=compression_tolerance, - storage_options_input=storage_options_input, - storage_options_output=storage_options_output, - overwrite=overwrite, - grid_overrides=grid_overrides, - ) - - -@cli.command(name="export") -@argument("mdio-file", type=STRING) -@argument("segy-path", type=Path(exists=False)) -@option( - "-access", - "--access-pattern", - required=False, - default="012", - help="Access pattern of the file", - type=STRING, - show_default=True, -) -@option( - "-storage", - "--storage-options", - required=False, - help="Custom storage options for cloud backends.", - type=JSON, -) -@option( - "-endian", - "--endian", - required=False, - default="big", - help="Endianness of the SEG-Y file", - type=Choice(["little", "big"]), - show_default=True, - show_choices=True, -) -def segy_export( - mdio_file: str, - segy_path: str, - access_pattern: str, - storage_options: dict[str, Any], - endian: str, + if storage_input is not None: + input_path = UPath(input_path, storage_options=storage_input) + + if storage_output is not None: + output_path = UPath(output_path, storage_options=storage_output) + + if not input_path.is_file(): + typer.secho(f"Input file '{input_path}' does not exist.", fg="red", err=True) + raise typer.Abort from None + + # Preview textual header before template selection when building spec interactively + preselected_encoding: str | None = None + if interactive and segy_spec is None: + preselected_encoding = _interactive_text_header_preview_select_encoding(input_path) + + if mdio_template: + mdio_template_obj = load_mdio_template(mdio_template) + elif interactive: + mdio_template_obj = prompt_for_mdio_template() + else: + typer.secho( + "MDIO template is required in non-interactive mode. Provide --mdio-template or use --interactive.", + fg="red", + err=True, + ) + raise typer.Exit(2) + + # Load or create SEG-Y specification + if segy_spec: + segy_spec_obj = load_segy_spec(segy_spec) + elif interactive: + segy_spec_obj = create_segy_spec(input_path, mdio_template_obj, preselected_encoding=preselected_encoding) + else: + typer.secho( + "SEG-Y spec is required in non-interactive mode. Provide --segy-spec or use --interactive to build one.", + fg="red", + err=True, + ) + raise typer.Exit(2) + + # Perform conversion + from mdio.converters import segy_to_mdio + + try: + segy_to_mdio( + segy_spec=segy_spec_obj, + mdio_template=mdio_template_obj, + input_path=input_path, + output_path=output_path, + overwrite=overwrite, + ) + except FileExistsError: + typer.secho(f"Output location '{output_path}' exists. Use `--overwrite` flag to overwrite.", fg="red", err=True) + raise typer.Abort from None + except (MDIOMissingFieldError, GridTraceSparsityError) as err: + typer.secho(str(err), fg="red", err=True) + raise typer.Abort from None + + print(f"SEG-Y to MDIO conversion successful: {input_path} -> {output_path}") + + +MdioInType = Annotated[UPath, typer.Argument(help="Path to the input MDIO file.", click_type=UPathParamType())] +SegyOutType = Annotated[UPath, typer.Argument(help="Path to the output SEG-Y file.", click_type=UPathParamType())] +StorageOptionType = Annotated[dict | None, typer.Option(help="Options for remote storage.", click_type=JSONParamType())] +OverwriteType = Annotated[bool, typer.Option(help="Overwrite the MDIO file if it exists.")] + + +@app.command(name="export") +def segy_export( # noqa: PLR0913 + input_path: MdioInType, + output_path: SegyOutType, + segy_spec: SegySpecType = None, + storage_input: StorageOptionType = None, + overwrite: OverwriteType = False, + interactive: InteractiveType = False, ) -> None: - """Export MDIO file to SEG-Y. + """Export an MDIO dataset to SEG-Y. + + \b + Status: not yet implemented. This command currently raises NotImplementedError. + + \b + Example (will error until implemented): + - mdio segy export in.mdio out.segy --segy-spec spec.json + """ + if storage_input is not None: + input_path = UPath(input_path, storage_options=storage_input) - SEG-Y format is explained in the "segy" group of the command line interface. To see - additional information run: + msg = f"Exporting MDIO to SEG-Y is not yet supported. Args: {locals()}" + raise NotImplementedError(msg) - mdio segy --help - MDIO allows exporting multidimensional seismic data back to the flattened seismic format - SEG-Y, to be used in data transmission. +if __name__ == "__main__": + app() - The input headers are preserved as is, and will be transferred to the output file. - The user has control over the endianness, and the floating point data type. However, by default - we export as Big-Endian IBM float, per the SEG-Y format's default. +# --- Helpers for textual header preview --- - The input MDIO can be local or cloud based. However, the output SEG-Y will be generated locally. + +def _read_text_headers(input_path: UPath, segy_spec: SegySpec) -> tuple[str, list[str]]: + """Read main and extended textual headers from a SEG-Y file using the provided spec. + + Important: Avoid SegyFile.text_header and .ext_text_header cached properties so that + switching encodings reflects immediately. We read raw bytes and decode via the spec. + """ + from segy import SegyFile + + sf = SegyFile(input_path.as_posix(), spec=segy_spec) + + # Read and decode the main textual header directly via the spec + # We need to clear the cached properties + if hasattr(sf.spec.text_header, "processor"): + del sf.spec.text_header.processor + if hasattr(sf, "text_header"): + del sf.text_header + + main: str = sf.text_header + + # Read and decode extended textual headers (if present) directly via the spec + ext: list[str] = [] + ext_spec = sf.spec.ext_text_header + if ext_spec is not None: + ext_buf = sf.fs.read_block(fn=sf.url, offset=ext_spec.offset, length=ext_spec.itemsize) + ext = ext_spec.decode(ext_buf) + + return main, ext + + +def _pager(content: str) -> None: + """Show content via a pager if available; fallback to plain print.""" + try: + click.echo_via_pager(content) + except Exception: # pragma: no cover - fallback path + print(content) + + +def _format_header_bundle(main: str, ext_list: list[str] | None, encoding: str) -> str: + """Format textual headers nicely for display or saving.""" + lines: list[str] = [ + f"Textual Header (encoding={encoding})", + "-" * 60, + main.rstrip("\n"), + ] + if ext_list: + for i, ext in enumerate(ext_list, start=1): + lines.extend( + [ + "", + f"Extended Text Header #{i} (encoding={encoding})", + "-" * 60, + ext.rstrip("\n"), + ] + ) + return "\n".join(lines) + + +def _interactive_text_header_preview_select_encoding(input_path: UPath) -> str: + """Run textual header preview before template selection and return the chosen encoding. + + Uses a temporary REV1 spec and starts with EBCDIC by default. Allows switching + between EBCDIC/ASCII and saving the displayed headers. """ - # Lazy import to reduce CLI startup time - from mdio import mdio_to_segy # noqa: PLC0415 - - mdio_to_segy( - mdio_path_or_buffer=mdio_file, - output_segy_path=segy_path, - access_pattern=access_pattern, - storage_options=storage_options, - endian=endian, - ) + from segy.standards.registry import get_segy_standard + + text_encoding = TextHeaderEncoding.EBCDIC + segy_spec_preview = get_segy_standard(SegyStandard.REV1) + segy_spec_preview.text_header.encoding = text_encoding + if segy_spec_preview.ext_text_header is not None: + segy_spec_preview.ext_text_header.spec.encoding = text_encoding + segy_spec_preview.endianness = None + + if questionary.confirm("Preview textual header now?", default=True).ask(): + include_ext = False + while True: + main_txt, ext_txt_list = _read_text_headers(input_path, segy_spec_preview) + ext_to_show = ext_txt_list if include_ext else [] + bundle = _format_header_bundle(main_txt, ext_to_show, segy_spec_preview.text_header.encoding) + _pager(bundle) + + if ext_txt_list: + include_ext = questionary.confirm( + "Include extended text headers in the next preview?", default=include_ext + ).ask() + + did_save = False + if questionary.confirm("Save displayed header(s) to a file?", default=False).ask(): + default_hdr_path = input_path.with_name(f"{input_path.stem}_text_header.txt") + out_hdr_uri = questionary.text( + "Filename for textual header:", default=default_hdr_path.as_posix() + ).ask() + if out_hdr_uri: + with UPath(out_hdr_uri).open("w") as fh: + fh.write(bundle) + print(f"Textual header saved to '{out_hdr_uri}'.") + did_save = True + + # If the user saved, exit the preview loop without asking to preview again + if did_save: + break + + if not questionary.confirm("Switch encoding and preview again?", default=False).ask(): + break + + new_enc = prompt_for_text_encoding() + text_encoding = new_enc or text_encoding + segy_spec_preview.text_header.encoding = text_encoding + if segy_spec_preview.ext_text_header is not None: + segy_spec_preview.ext_text_header.spec.encoding = text_encoding + + return text_encoding diff --git a/src/mdio/commands/version.py b/src/mdio/commands/version.py new file mode 100644 index 000000000..5ff8647e6 --- /dev/null +++ b/src/mdio/commands/version.py @@ -0,0 +1,13 @@ +"""Version command.""" + +import typer + +from mdio import __version__ + +app = typer.Typer() + + +@app.command() +def version() -> None: + """Print the version of the CLI.""" + print(f"MDIO CLI Version {__version__}") diff --git a/src/mdio/converters/segy.py b/src/mdio/converters/segy.py index 7914f410b..c267bd3ae 100644 --- a/src/mdio/converters/segy.py +++ b/src/mdio/converters/segy.py @@ -36,6 +36,7 @@ from mdio.core.utils_write import MAX_COORDINATES_BYTES from mdio.core.utils_write import MAX_SIZE_LIVE_MASK from mdio.core.utils_write import get_constrained_chunksize +from mdio.exceptions import MDIOMissingFieldError from mdio.segy import blocked_io from mdio.segy.file import get_segy_file_info from mdio.segy.scalar import SCALE_COORDINATE_KEYS @@ -508,7 +509,7 @@ def _validate_spec_in_template(segy_spec: SegySpec, mdio_template: AbstractDatas f"Required fields {sorted(missing_fields)} for template {mdio_template.name} " f"not found in the provided segy_spec" ) - raise ValueError(err) + raise MDIOMissingFieldError(err) def segy_to_mdio( # noqa PLR0913 diff --git a/src/mdio/exceptions.py b/src/mdio/exceptions.py index 1aba7cc92..38dd0ed89 100644 --- a/src/mdio/exceptions.py +++ b/src/mdio/exceptions.py @@ -63,3 +63,7 @@ class MDIONotFoundError(MDIOError): class MDIOMissingVariableError(MDIOError): """Raised when a variable is missing from the MDIO dataset.""" + + +class MDIOMissingFieldError(MDIOError): + """Raised when a template key is missing from the SEG-Y file to be ingeseted.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..7f45d5a85 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,19 @@ +"""Test cases for the __main__ module.""" + +import pytest +from typer.testing import CliRunner + +from mdio.cli import app + + +@pytest.fixture +def runner() -> CliRunner: + """Fixture for invoking command-line interfaces.""" + return CliRunner() + + +def test_cli_version(runner: CliRunner) -> None: + """Check if version prints without error.""" + result = runner.invoke(app, args=["version"]) + assert result.exit_code == 0 + assert "MDIO CLI Version" in result.output diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 43ee2e6cd..000000000 --- a/tests/test_main.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Test cases for the __main__ module.""" - -import os -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from mdio import __main__ - - -@pytest.fixture -def runner() -> CliRunner: - """Fixture for invoking command-line interfaces.""" - return CliRunner() - - -# TODO(Altay): Redesign and implement the new v1 CLI -# https://github.com/TGSAI/mdio-python/issues/646 -@pytest.mark.skip(reason="CLI hasn't been updated to work with v1 yet.") -@pytest.mark.dependency -def test_main_succeeds( - runner: CliRunner, segy_input: Path, zarr_tmp: Path -) -> None: # pragma: no cover - test is skipped - """It exits with a status code of zero.""" - cli_args = ["segy", "import", str(segy_input), str(zarr_tmp)] - cli_args.extend(["--header-locations", "181,185"]) - cli_args.extend(["--header-names", "inline,crossline"]) - - result = runner.invoke(__main__.main, args=cli_args) - assert result.exit_code == 0 - - -@pytest.mark.skip(reason="CLI hasn't been updated to work with v1 yet.") -@pytest.mark.dependency(depends=["test_main_succeeds"]) -def test_main_cloud( - runner: CliRunner, segy_input_uri: str, zarr_tmp: Path -) -> None: # pragma: no cover - tests is skipped - """It exits with a status code of zero.""" - os.environ["MDIO__IMPORT__CLOUD_NATIVE"] = "true" - cli_args = ["segy", "import", segy_input_uri, str(zarr_tmp)] - cli_args.extend(["--header-locations", "181,185"]) - cli_args.extend(["--header-names", "inline,crossline"]) - cli_args.extend(["--overwrite"]) - - result = runner.invoke(__main__.main, args=cli_args) - assert result.exit_code == 0 - - -@pytest.mark.skip(reason="CLI hasn't been updated to work with v1 yet.") -@pytest.mark.dependency(depends=["test_main_succeeds"]) -def test_main_info_succeeds(runner: CliRunner, zarr_tmp: Path) -> None: # pragma: no cover - tests is skipped - """It exits with a status code of zero.""" - cli_args = ["info"] - cli_args.extend([str(zarr_tmp)]) - - result = runner.invoke(__main__.main, args=cli_args) - assert result.exit_code == 0 - - -@pytest.mark.skip(reason="CLI hasn't been updated to work with v1 yet.") -@pytest.mark.dependency(depends=["test_main_succeeds"]) -def test_main_copy(runner: CliRunner, zarr_tmp: Path, zarr_tmp2: Path) -> None: # pragma: no cover - tests is skipped - """It exits with a status code of zero.""" - cli_args = ["copy", str(zarr_tmp), str(zarr_tmp2), "-headers", "-traces"] - - result = runner.invoke(__main__.main, args=cli_args) - assert result.exit_code == 0 - - -def test_cli_version(runner: CliRunner) -> None: - """Check if version prints without error.""" - cli_args = ["--version"] - result = runner.invoke(__main__.main, args=cli_args) - assert result.exit_code == 0 diff --git a/tests/unit/test_segy_spec_validation.py b/tests/unit/test_segy_spec_validation.py index afdfeac6a..2b7e1ecfa 100644 --- a/tests/unit/test_segy_spec_validation.py +++ b/tests/unit/test_segy_spec_validation.py @@ -10,6 +10,7 @@ from mdio.builder.templates.base import AbstractDatasetTemplate from mdio.converters.segy import _validate_spec_in_template +from mdio.exceptions import MDIOMissingFieldError class TestValidateSpecInTemplate: @@ -43,7 +44,7 @@ def test_validation_fails_with_missing_fields(self) -> None: segy_spec = spec.customize(trace_header_fields=header_fields) # Should raise ValueError listing the missing fields - with pytest.raises(ValueError, match=r"Required fields.*not found in.*segy_spec") as exc_info: + with pytest.raises(MDIOMissingFieldError, match=r"Required fields.*not found in.*segy_spec") as exc_info: _validate_spec_in_template(segy_spec, template) error_message = str(exc_info.value) @@ -67,7 +68,7 @@ def test_validation_fails_with_missing_coordinate_scalar(self) -> None: segy_spec = spec.customize(trace_header_fields=standard_fields) # Should raise ValueError for missing coordinate_scalar - with pytest.raises(ValueError, match=r"Required fields.*not found in.*segy_spec") as exc_info: + with pytest.raises(MDIOMissingFieldError, match=r"Required fields.*not found in.*segy_spec") as exc_info: _validate_spec_in_template(segy_spec, template) error_message = str(exc_info.value) diff --git a/uv.lock b/uv.lock index 894419f5c..b544e8c87 100644 --- a/uv.lock +++ b/uv.lock @@ -484,20 +484,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] -[[package]] -name = "click-params" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "deprecated" }, - { name = "validators" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/49/57e60d9e1b78fd21fbaeda0725ac311595c35d8682dace6b71b274a43b90/click_params-0.5.0.tar.gz", hash = "sha256:5fe97b9459781a3b43b84fe4ec0065193e1b0d5cf6dc77897fe20c31f478d7ff", size = 11097, upload-time = "2023-11-23T11:54:31.315Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/c7/a04832e84f1c613194231a657612aee2e377d63a44a5847386c83c38bbd6/click_params-0.5.0-py3-none-any.whl", hash = "sha256:bbb2efe44197ab896bffcb50f42f22240fb077e6756b568fbdab3e1700b859d6", size = 13152, upload-time = "2023-11-23T11:54:29.599Z" }, -] - [[package]] name = "cloudpickle" version = "3.1.1" @@ -805,18 +791,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "deprecated" -version = "1.2.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -1282,11 +1256,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -1891,16 +1865,15 @@ name = "multidimio" version = "1.0.9" source = { editable = "." } dependencies = [ - { name = "click" }, - { name = "click-params" }, { name = "dask" }, { name = "fsspec" }, { name = "pint" }, { name = "psutil" }, { name = "pydantic" }, - { name = "rich" }, + { name = "questionary" }, { name = "segy" }, { name = "tqdm" }, + { name = "typer" }, { name = "universal-pathlib" }, { name = "xarray" }, { name = "zarr" }, @@ -1943,17 +1916,15 @@ docs = [ { name = "myst-nb" }, { name = "sphinx" }, { name = "sphinx-autobuild" }, - { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, + { name = "sphinxcontrib-typer" }, ] [package.metadata] requires-dist = [ { name = "adlfs", marker = "extra == 'cloud'", specifier = ">=2025.8.0" }, { name = "bokeh", marker = "extra == 'distributed'", specifier = ">=3.8.0" }, - { name = "click", specifier = ">=8.3.0" }, - { name = "click-params", specifier = ">=0.5.0" }, { name = "dask", specifier = ">=2025.9.1" }, { name = "distributed", marker = "extra == 'distributed'", specifier = ">=2025.9.1" }, { name = "fsspec", specifier = ">=2025.9.0" }, @@ -1961,10 +1932,11 @@ requires-dist = [ { name = "pint", specifier = ">=0.25.0" }, { name = "psutil", specifier = ">=7.1.0" }, { name = "pydantic", specifier = ">=2.12.0" }, - { name = "rich", specifier = ">=14.1.0" }, + { name = "questionary", specifier = ">=2.1.1" }, { name = "s3fs", marker = "extra == 'cloud'", specifier = ">=2025.9.0" }, { name = "segy", specifier = ">=0.5.3" }, { name = "tqdm", specifier = ">=4.67.1" }, + { name = "typer", specifier = ">=0.20.0" }, { name = "universal-pathlib", specifier = ">=0.3.3" }, { name = "xarray", specifier = ">=2025.10.1" }, { name = "zarr", specifier = ">=3.1.3" }, @@ -1995,9 +1967,9 @@ docs = [ { name = "myst-nb", specifier = ">=1.3.0" }, { name = "sphinx", specifier = ">=8.2.3" }, { name = "sphinx-autobuild", specifier = ">=2025.8.25" }, - { name = "sphinx-click", specifier = ">=6.1.0" }, { name = "sphinx-copybutton", specifier = ">=0.5.2" }, { name = "sphinx-design", specifier = ">=0.6.1" }, + { name = "sphinxcontrib-typer", specifier = ">=0.6.2" }, ] [[package]] @@ -2936,6 +2908,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "rapidfuzz" version = "3.14.1" @@ -3381,20 +3365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, ] -[[package]] -name = "sphinx-click" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "docutils" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/4b/c433ea57136eac0ccb8d76d33355783f1e6e77f1f13dc7d8f15dba2dc024/sphinx_click-6.1.0.tar.gz", hash = "sha256:c702e0751c1a0b6ad649e4f7faebd0dc09a3cc7ca3b50f959698383772f50eef", size = 26855, upload-time = "2025-09-11T11:05:45.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/95/a2fa680f02ee9cbe4532169d2e60b102fe415b6cfa25584ac2d112e4c43b/sphinx_click-6.1.0-py3-none-any.whl", hash = "sha256:7dbed856c3d0be75a394da444850d5fc7ecc5694534400aa5ed4f4849a8643f9", size = 8931, upload-time = "2025-09-11T11:05:43.897Z" }, -] - [[package]] name = "sphinx-copybutton" version = "0.5.2" @@ -3473,6 +3443,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "sphinxcontrib-typer" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "typer-slim", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/98/8b57484e16a24ac5ff73a473186e80a8f38344fa96288b757bb01fe54e07/sphinxcontrib_typer-0.6.2.tar.gz", hash = "sha256:bc872805f9680adb20666bdc11ee5d91b12a66554d20dfdc04488d6599f49081", size = 382805, upload-time = "2025-09-23T13:37:35.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/75/ab227e91acb0666130494ba8f925a486055ba8d91efb248a1cef605d5f25/sphinxcontrib_typer-0.6.2-py3-none-any.whl", hash = "sha256:33d1dbd17e015eeab1e240df7f86ce4c08adf0c1d78f7133d31f6db0cc913f61", size = 14521, upload-time = "2025-09-23T13:37:33.615Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.44" @@ -3651,7 +3634,7 @@ wheels = [ [[package]] name = "typer" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -3659,9 +3642,28 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/d6/489402eda270c00555213bdd53061b23a0ae2b5dccbfe428ebcc9562d883/typer_slim-0.19.2.tar.gz", hash = "sha256:6f601e28fb8249a7507f253e35fb22ccc701403ce99bea6a9923909ddbfcd133", size = 104788, upload-time = "2025-09-23T09:47:42.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/19/7aef771b3293e1b7c749eebb2948bb7ccd0e9b56aa222eb4d5e015087730/typer_slim-0.19.2-py3-none-any.whl", hash = "sha256:1c9cdbbcd5b8d30f4118d3cb7c52dc63438b751903fbd980a35df1dfe10c6c91", size = 46806, upload-time = "2025-09-23T09:47:41.385Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "rich" }, + { name = "shellingham" }, ] [[package]] @@ -3727,24 +3729,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.37.0" +version = "0.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, -] - -[[package]] -name = "validators" -version = "0.22.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/21/40a249498eee5a244a017582c06c0af01851179e2617928063a3d628bc8f/validators-0.22.0.tar.gz", hash = "sha256:77b2689b172eeeb600d9605ab86194641670cdb73b60afd577142a9397873370", size = 41479, upload-time = "2023-09-02T09:17:59.054Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0c/785d317eea99c3739821718f118c70537639aa43f96bfa1d83a71f68eaf6/validators-0.22.0-py3-none-any.whl", hash = "sha256:61cf7d4a62bbae559f2e54aed3b000cea9ff3e2fdbe463f51179b92c58c9585a", size = 26195, upload-time = "2023-09-02T09:17:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] [[package]]