Skip to content

Commit

Permalink
feat: standardize command line interface (#16)
Browse files Browse the repository at this point in the history
* add tomlkit to requirements

* add compact generate command, toml input file

* test cli on Travis

* fix tests for new cli

* update test data files for new cli

* fix mypy errors

* remove lint

* add tests for 100% coverage
  • Loading branch information
mcflugen committed Aug 22, 2020
1 parent 46e99a9 commit 494aecd
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 211 deletions.
6 changes: 6 additions & 0 deletions .travis.yml
Expand Up @@ -53,4 +53,10 @@ script:
- pip install -r requirements-testing.txt
- pip install pytest pytest-cov pytest-datadir pytest-benchmark coveralls
- pytest -vvv --cov=compaction --cov-report=xml:$(pwd)/coverage.xml --mypy
- compact --help
- compact --version
- compact generate compact.toml
- compact generate porosity.csv
- mkdir example
- compact --cd=example setup run
after_success: coveralls
209 changes: 134 additions & 75 deletions compaction/cli.py
@@ -1,13 +1,15 @@
import os
import pathlib
import sys
import warnings
from functools import partial
from io import StringIO
from typing import Optional, TextIO

import click
import numpy as np # type: ignore
import pandas # type: ignore
import yaml
import tomlkit as toml # type: ignore

from .compaction import compact as _compact

Expand All @@ -16,12 +18,82 @@
err = partial(click.secho, fg="red", err=True)


def load_config(file: Optional[TextIO] = None):
def _tomlkit_to_popo(d):
"""Convert a tomlkit doc to plain-old-python objects.
Examples
--------
>>> import tomlkit
>>> from compaction.cli import _tomlkit_to_popo
>>> contents = \"\"\"
... [[test]]
... int_value = 3
... float_value = 3.14
... str_value = "pi"
... bool_value = true
... \"\"\"
>>> doc = tomlkit.parse(contents)
>>> doc
{'test': [{'int_value': 3, 'float_value': 3.14, 'str_value': 'pi', 'bool_value': True}]}
>>> isinstance(doc["test"][0]["int_value"], tomlkit.items.Item)
True
>>> isinstance(doc["test"][0]["float_value"], tomlkit.items.Item)
True
>>> isinstance(doc["test"][0]["str_value"], tomlkit.items.Item)
True
>>> popo = _tomlkit_to_popo(doc)
>>> popo
{'test': [{'int_value': 3, 'float_value': 3.14, 'str_value': 'pi', 'bool_value': True}]}
>>> isinstance(popo["test"][0]["int_value"], tomlkit.items.Item)
False
>>> isinstance(popo["test"][0]["float_value"], tomlkit.items.Item)
False
>>> isinstance(popo["test"][0]["str_value"], tomlkit.items.Item)
False
>>> isinstance(popo["test"][0]["bool_value"], tomlkit.items.Item)
False
"""
try:
result = getattr(d, "value")
except AttributeError:
result = d

if isinstance(result, list):
result = [_tomlkit_to_popo(x) for x in result]
elif isinstance(result, dict):
result = {
_tomlkit_to_popo(key): _tomlkit_to_popo(val) for key, val in result.items()
}
elif isinstance(result, toml.items.Integer):
result = int(result)
elif isinstance(result, toml.items.Float):
result = float(result)
elif isinstance(result, (toml.items.String, str)):
result = str(result)
elif isinstance(result, (toml.items.Bool, bool)):
result = bool(result)
else:
if not isinstance(result, (int, float, str, bool)):
warnings.warn( # pragma: no cover
"unexpected type ({0!r}) encountered when converting toml to a dict".format(
result.__class__.__name__
)
)

return result


def load_config(stream: Optional[TextIO] = None):
"""Load compaction config file.
Parameters
----------
fname : file-like, optional
stream : file-like, optional
Opened config file or ``None``. If ``None``, return default
values.
Expand All @@ -31,15 +103,30 @@ def load_config(file: Optional[TextIO] = None):
Config parameters.
"""
conf = {
"c": 5e-8,
"porosity_min": 0.0,
"porosity_max": 0.5,
"rho_grain": 2650.0,
"rho_void": 1000.0,
"compact" : {
"constants": {
"c": 5e-8,
"porosity_min": 0.0,
"porosity_max": 0.5,
"rho_grain": 2650.0,
"rho_void": 1000.0,
}
}
}
if file is not None:
conf.update(yaml.safe_load(file))
return conf
if stream is not None:
try:
local_params = toml.parse(stream.read())["compact"]
except KeyError:
local_params = {"constants": {}}

try:
local_constants = local_params["constants"]
except KeyError:
local_constants = {}

conf["compact"]["constants"].update(local_constants)

return _tomlkit_to_popo(conf).pop("compact")


def _contents_of_input_file(infile: str) -> str:
Expand All @@ -52,8 +139,8 @@ def as_csv(data, header=None):
return contents

contents = {
"config": yaml.dump(params, default_flow_style=False),
"porosity": as_csv(
"compact.toml": toml.dumps(dict(compact=params)),
"porosity.csv": as_csv(
[[100.0, 0.5], [100.0, 0.5], [100.0, 0.5]],
header="Layer Thickness [m], Porosity [-]",
),
Expand All @@ -62,121 +149,93 @@ def as_csv(data, header=None):
return contents[infile]


def run_compaction(
src: Optional[TextIO] = None, dest: Optional[TextIO] = None, **kwds
) -> None:
src = src or sys.stdin
dest = dest or sys.stdout

def run_compaction(src: str, dest: str, **kwds) -> None:
init = pandas.read_csv(src, names=("dz", "porosity"), dtype=float, comment="#")

dz_new = np.empty_like(init.dz)
porosity_new = _compact(
init.dz.values, init.porosity.values, return_dz=dz_new, **kwds
)

out = pandas.DataFrame.from_dict({"dz": dz_new, "porosity": porosity_new})
print("# Layer Thickness [m], Porosity [-]", file=dest)
out.to_csv(dest, index=False, header=False)
result = pandas.DataFrame.from_dict({"dz": dz_new, "porosity": porosity_new})

with open(dest, "w") as fp:
print("# Layer Thickness [m], Porosity [-]", file=fp)
result.to_csv(fp, index=False, header=False)

@click.group()

@click.group(chain=True)
@click.version_option()
def compact() -> None:
@click.option(
"--cd",
default=".",
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
help="chage to directory, then execute",
)
def compact(cd) -> None:
"""Compact layers of sediment.
\b
Examples:
Create a folder with example input files,
$ compact setup compact-example
$ mkdir compaction-example && cd compaction-example
$ compact setup
Run a simulation using the examples input files,
$ compact run compact-example/porosity.csv
$ compact run
"""
pass # pragma: no cover
os.chdir(cd) # pragma: no cover


@compact.command()
@click.version_option()
@click.option("-v", "--verbose", is_flag=True, help="Emit status messages to stderr.")
@click.option("--dry-run", is_flag=True, help="Do not actually run the model")
@click.option(
"--config",
type=click.Path(
exists=False, file_okay=True, dir_okay=False, readable=True, allow_dash=False
),
default="config.yaml",
is_eager=True,
help="Read configuration from PATH.",
)
@click.argument("src", type=click.File(mode="r"))
@click.argument("dest", default="-", type=click.File(mode="w"))
def run(src: TextIO, dest: TextIO, config: str, dry_run: bool, verbose: bool) -> None:
def run(dry_run: bool, verbose: bool) -> None:
"""Run a simulation."""
try:
from_stdin = src.name == "<stdin>"
except AttributeError:
from_stdin = True
try:
from_stdout = dest.name == "<stdout>"
except AttributeError:
from_stdout = True

config_path = pathlib.Path(config)
if from_stdin:
rundir = pathlib.Path(".")
else:
rundir = pathlib.Path(src.name).parent.resolve()

with open(rundir / config_path, "r") as fp:
with open("compact.toml", "r") as fp:
params = load_config(fp)

if verbose:
out(yaml.dump(params, default_flow_style=False))
out(toml.dumps(params))

if dry_run:
out("Nothing to do. 😴")
else:
run_compaction(src, dest, **params)
run_compaction("porosity.csv", "porosity-out.csv", **params["constants"])

out("💥 Finished! 💥")
out("Output written to {0}".format("<stdout>" if from_stdout else dest.name))

sys.exit(0)
out("Output written to {0}".format("porosity-out.csv"))


@compact.command()
@click.argument(
"infile", type=click.Choice(["config", "porosity"]),
"infile", type=click.Choice(["compact.toml", "porosity.csv"]),
)
def show(infile: str) -> None:
def generate(infile: str) -> None:
"""Show example input files."""
print(_contents_of_input_file(infile))


@compact.command()
@click.argument(
"dest", type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
)
def setup(dest: str) -> None:
def setup() -> None:
"""Setup a folder of input files for a simulation."""
folder = pathlib.Path(dest)

files = [pathlib.Path(fname) for fname in ["porosity.csv", "config.yaml"]]
files = [pathlib.Path(fname) for fname in ["porosity.csv", "compact.toml"]]

existing_files = [folder / name for name in files if (folder / name).exists()]
existing_files = [str(file_) for file_ in files if file_.exists()]
if existing_files:
for name in existing_files:
err(
f"{name}: File exists. Either remove and then rerun or choose a different destination folder",
)
else:
for fname in files:
with open(folder / fname, "w") as fp:
print(_contents_of_input_file(fname.stem), file=fp)
print(str(folder / "porosity.csv"))
for file_ in files:
with open(file_, "w") as fp:
print(_contents_of_input_file(file_.name), file=fp)
print(pathlib.Path.cwd())

sys.exit(len(existing_files))
if existing_files:
sys.exit(len(existing_files))
1 change: 1 addition & 0 deletions requirements.txt
Expand Up @@ -3,4 +3,5 @@ landlab
numpy
pandas
pyyaml
tomlkit
scipy

0 comments on commit 494aecd

Please sign in to comment.