Skip to content

Commit

Permalink
Merge pull request #57 from davemfish/feature/43-cli-click
Browse files Browse the repository at this point in the history
Feature: Command-line interface
  • Loading branch information
phargogh authored Jan 6, 2025
2 parents 9092462 + 866b15f commit 1fb8abd
Show file tree
Hide file tree
Showing 8 changed files with 639 additions and 13 deletions.
78 changes: 68 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ Supported datatypes include:
* compressed formats supported by `frictionless`


See `requirements.txt` for dependencies
See `requirements.txt` for dependencies.

This library comes with a command-line interface (CLI) called `geometamaker`.
Many of the examples below show how to use the Python interface, and then
how to do the same thing, if possible, using the CLI.

### Creating & adding metadata to file:

##### Python

```python
import geometamaker

Expand Down Expand Up @@ -38,22 +44,66 @@ resource.set_band_description(
resource.write()
```

##### CLI
```
geometamaker describe data/watershed_gura.shp
```
The CLI does not provide options for setting metadata properties such as
keywords, field or band descriptions, or other properties that require
user-input. If you create a metadata document with the CLI, you may wish
to add these values manually by editing the
`watershed_gura.shp.yml` file in a text editor.

### Creating metadata for a batch of files:

#### Python
```python
import os

import geometamaker

data_dir = 'C:/Users/dmf/projects/invest/data/invest-sample-data'
for path, dirs, files in os.walk(data_dir):
for file in files:
filepath = os.path.join(path, file)
print(filepath)
try:
resource = geometamaker.describe(filepath)
except ValueError as err:
print(err)
resource.write()
geometamaker.describe_dir(data_dir, recursive=True)
```

#### CLI
```
geometamaker describe -r data/invest-sample-data
```

### Validating a metadata document:
If you have manually edited a `.yml` metadata document,
it is a good idea to validate it for correct syntax, properties, and types.

##### Python
```python
import geometamaker

document_path = 'data/watershed_gura.shp.yml'
error = geometamaker.validate(document_path)
print(error)
```

##### CLI
```
geometamaker validate data/watershed_gura.shp.yml
```

### Validating all metadata documents in a directory

##### Python
```python
import geometamaker

directory_path = 'data/'
yaml_files, messages = geometamaker.validate_dir(data)
for filepath, msg in zip(yaml_files, messages):
print(f'{filepath}: {msg}')
```

##### CLI
```
geometamaker validate data
```

### Configuring default values for metadata properties:
Expand Down Expand Up @@ -90,6 +140,8 @@ resource = geometamaker.describe(data_path, profile=profile)
```

#### Store a Profile in user-configuration

##### Python
```python
import os

Expand All @@ -110,6 +162,12 @@ data_path = 'data/watershed_gura.shp'
resource = geometamaker.describe(data_path)
```

##### CLI
```
geometamaker config
```
This will prompt the user to enter their profile information.
Also see `geometamaker config --help`.

### For a complete list of methods:
https://geometamaker.readthedocs.io/en/latest/api/geometamaker.html
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ classifiers = [
# using the corresponding setup args `install_requires` and `extras_require`
dynamic = ["version", "dependencies", "optional-dependencies"]

[project.scripts]
geometamaker = "geometamaker.cli:cli"

[build-system]
requires = ["setuptools >= 40.6.0", "wheel", "setuptools_scm"]
build-backend = "setuptools.build_meta"
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# This file records the packages and requirements needed in order for
# the library to work as expected. And to run tests.
aiohttp
click
fsspec
GDAL
frictionless
Expand Down
5 changes: 4 additions & 1 deletion src/geometamaker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import importlib.metadata

from .geometamaker import describe
from .geometamaker import describe_dir
from .geometamaker import validate
from .geometamaker import validate_dir
from .config import Config
from .models import Profile


__version__ = importlib.metadata.version('geometamaker')

__all__ = ('describe', 'Config', 'Profile')
__all__ = ('describe', 'describe_dir', 'validate', 'validate_dir', 'Config', 'Profile')
158 changes: 158 additions & 0 deletions src/geometamaker/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import logging
import os
import sys

import click
from pydantic import ValidationError

import geometamaker

ROOT_LOGGER = logging.getLogger()
ROOT_LOGGER.setLevel(logging.DEBUG)
HANDLER = logging.StreamHandler(sys.stdout)
FORMATTER = logging.Formatter(
fmt='%(asctime)s %(name)-18s %(levelname)-8s %(message)s',
datefmt='%m/%d/%Y %H:%M:%S ')
HANDLER.setFormatter(FORMATTER)


@click.command(
help='''Describe properties of a dataset given by FILEPATH and write this
metadata to a .yml sidecar file. Or if FILEPATH is a directory, describe
all datasets within.''',
short_help='Generate metadata for geospatial or tabular data, or zip archives.')
@click.argument('filepath', type=click.Path(exists=True))
@click.option('-r', '--recursive', is_flag=True, default=False,
help='if FILEPATH is a directory, describe files '
'in all subdirectories')
@click.option('-nw', '--no-write', is_flag=True, default=False,
help='Dump metadata to stdout instead of to a .yml file. '
'This option is ignored if `filepath` is a directory')
def describe(filepath, recursive, no_write):
if os.path.isdir(filepath):
if no_write:
click.echo('the -nw, or --no-write, flag is ignored when '
'describing all files in a directory.')
geometamaker.describe_dir(
filepath, recursive=recursive)
else:
resource = geometamaker.describe(filepath)
if no_write:
click.echo(geometamaker.utils.yaml_dump(
resource.model_dump(exclude=['metadata_path'])))
else:
resource.write()


def echo_validation_error(error, filepath):
summary = u'\u2715' + f' {filepath}: {error.error_count()} validation errors'
click.secho(summary, fg='bright_red')
for e in error.errors():
location = ', '.join(e['loc'])
msg_string = (f" {e['msg']}. [input_value={e['input']}, "
f"input_type={type(e['input']).__name__}]")
click.secho(location, bold=True)
click.secho(msg_string)


@click.command(
help='''Validate a .yml metadata document given by FILEPATH.
Or if FILEPATH is a directory, validate all documents within.''',
short_help='Validate metadata documents for syntax or type errors.')
@click.argument('filepath', type=click.Path(exists=True))
@click.option('-r', '--recursive', is_flag=True, default=False,
help='if `filepath` is a directory, validate documents '
'in all subdirectories.')
def validate(filepath, recursive):
if os.path.isdir(filepath):
file_list, message_list = geometamaker.validate_dir(
filepath, recursive=recursive)
for filepath, msg in zip(file_list, message_list):
if isinstance(msg, ValidationError):
echo_validation_error(msg, filepath)
else:
color = 'yellow'
icon = u'\u25CB'
if not msg:
color = 'bright_green'
icon = u'\u2713'
click.secho(f'{icon} {filepath} {msg}', fg=color)
else:
error = geometamaker.validate(filepath)
if error:
echo_validation_error(error, filepath)


def print_config(ctx, param, value):
if not value or ctx.resilient_parsing:
return
config = geometamaker.Config()
click.echo(config)
ctx.exit()


def delete_config(ctx, param, value):
if not value or ctx.resilient_parsing:
return
config = geometamaker.Config()
click.confirm(
f'Are you sure you want to delete {config.config_path}?',
abort=True)
config.delete()
ctx.exit()


@click.command(
short_help='''Configure GeoMetaMaker with information to apply to all
metadata descriptions''',
help='''When prompted, enter contact and data-license information
that will be stored in a user profile. This information will automatically
populate contact and license sections of any metadata described on your
system. Press enter to leave any field blank.''')
@click.option('--individual-name', prompt=True, default='')
@click.option('--email', prompt=True, default='')
@click.option('--organization', prompt=True, default='')
@click.option('--position-name', prompt=True, default='')
@click.option('--license-title', prompt=True, default='',
help='the name of a data license, e.g. "CC-BY-4.0"')
@click.option('--license-url', prompt=True, default='',
help='a url for a data license')
@click.option('-p', '--print', is_flag=True, is_eager=True,
callback=print_config, expose_value=False,
help='Print your current GeoMetaMaker configuration.')
@click.option('--delete', is_flag=True, is_eager=True,
callback=delete_config, expose_value=False,
help='Delete your configuration file.')
def config(individual_name, email, organization, position_name,
license_url, license_title):
contact = geometamaker.models.ContactSchema()
contact.individual_name = individual_name
contact.email = email
contact.organization = organization
contact.position_name = position_name

license = geometamaker.models.LicenseSchema()
license.path = license_url
license.title = license_title

profile = geometamaker.models.Profile(contact=contact, license=license)
config = geometamaker.Config()
config.save(profile)
click.echo(f'saved profile information to {config.config_path}')


@click.group()
@click.option('-v', 'verbosity', count=True, default=2, required=False,
help='''Override the default verbosity of logging. Use "-vvv" for
debug-level logging. Omit this flag for default,
info-level logging.''')
@click.version_option(message="%(version)s")
def cli(verbosity):
log_level = logging.ERROR - verbosity*10
HANDLER.setLevel(log_level)
ROOT_LOGGER.addHandler(HANDLER)


cli.add_command(describe)
cli.add_command(validate)
cli.add_command(config)
7 changes: 5 additions & 2 deletions src/geometamaker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,8 @@ def save(self, profile):

def delete(self):
"""Delete the config file."""
LOGGER.info(f'removing {self.config_path}')
os.remove(self.config_path)
try:
os.remove(self.config_path)
LOGGER.info(f'removed {self.config_path}')
except FileNotFoundError as error:
LOGGER.debug(error)
Loading

0 comments on commit 1fb8abd

Please sign in to comment.