From 24ab6200d2e529c1ac843f45496611423484c0c6 Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Thu, 13 Jun 2024 15:03:51 -0600
Subject: [PATCH 01/24] UW-627: Adding a test Jupyter notebook (#508)
We are adding an example Jupyter notebook to test using a pull request.
---
.gitignore | 1 +
notebooks/example.ipynb | 83 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+)
create mode 100644 notebooks/example.ipynb
diff --git a/.gitignore b/.gitignore
index fa050f56e..76191013a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
*.swp
.coverage
__pycache__
+.ipynb_checkpoints
\ No newline at end of file
diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb
new file mode 100644
index 000000000..53c48f765
--- /dev/null
+++ b/notebooks/example.ipynb
@@ -0,0 +1,83 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "afc23938-c4e4-4240-8fdc-66f0fff9ec06",
+ "metadata": {},
+ "source": [
+ "# Example Notebook\n",
+ "\n",
+ "## Example 1: Building YAML config from a Python dictionary\n",
+ "First, we need to import `uwtools.api.config` from the uwtools python package."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "a4a3fb02-06e0-4bf8-a78a-bfdb4fc474b3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import uwtools.api.config as config"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ec5c7a56-d605-4a58-bd48-4eaba9cd25bc",
+ "metadata": {},
+ "source": [
+ "The `config.get_yaml_config` method can create a `YAMLconfig` object when given a Python dictionary."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "71957053-4264-42d5-a4e7-43b50a0ed4e4",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "values:\n",
+ " date: 20240105\n",
+ " greeting: Good Night\n",
+ " recipient: Moon\n",
+ " repeat: 2"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# An example Python dictionary\n",
+ "dictionary = {\"values\":{\"date\":20240105, \"greeting\":\"Good Night\", \"recipient\":\"Moon\", \"repeat\":2}}\n",
+ "\n",
+ "# Build a YAMLconfig object from the dictionary\n",
+ "config.get_yaml_config(dictionary)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
From 429d5c12c48a9f199037d914f8c47bb07308496b Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Tue, 16 Jul 2024 13:02:11 -0600
Subject: [PATCH 02/24] UW-644 Use testbook to test the example Jupyter
notebook (#528)
Adds a unit testing file to test the example Jupyter notebook using testbook
Creates a make target for testing notebooks
Adds cells to the notebook that demonstrate writing a file to disk by rendering a template and yaml files to facilitate this
Adds cells to the notebook that include cell magics
---
Makefile | 5 +-
notebooks/Makefile | 9 ++
notebooks/example.ipynb | 165 ++++++++++++++++++++++++++--
notebooks/fixtures/user_config.yaml | 3 +
notebooks/fixtures/user_values.yaml | 3 +
notebooks/install-deps | 1 +
notebooks/pyproject.toml | 2 +
notebooks/tests/test_example.py | 52 +++++++++
8 files changed, 228 insertions(+), 12 deletions(-)
create mode 100644 notebooks/Makefile
create mode 100644 notebooks/fixtures/user_config.yaml
create mode 100644 notebooks/fixtures/user_values.yaml
create mode 100644 notebooks/install-deps
create mode 100644 notebooks/pyproject.toml
create mode 100644 notebooks/tests/test_example.py
diff --git a/Makefile b/Makefile
index f42433637..d0899a757 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
CHANNELS = $(addprefix -c ,$(shell tr '\n' ' ' <$(RECIPE_DIR)/channels)) -c local
METADEPS = $(addprefix $(RECIPE_DIR)/,meta.yaml) src/uwtools/resources/info.json
METAJSON = $(RECIPE_DIR)/meta.json
-TARGETS = clean-devenv devshell docs env format lint meta package test typecheck unittest
+TARGETS = clean-devenv devshell docs env format lint meta package test test-nb typecheck unittest
export RECIPE_DIR := $(shell cd ./recipe && pwd)
@@ -41,6 +41,9 @@ package: meta
test:
recipe/run_test.sh
+test-nb:
+ $(MAKE) -C notebooks test-nb
+
typecheck:
recipe/run_test.sh typecheck
diff --git a/notebooks/Makefile b/notebooks/Makefile
new file mode 100644
index 000000000..f6c162470
--- /dev/null
+++ b/notebooks/Makefile
@@ -0,0 +1,9 @@
+TARGETS = test-nb
+
+.PHONY: $(TARGETS)
+
+all:
+ $(error Valid targets are: $(TARGETS))
+
+test-nb:
+ pytest tests
diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb
index 53c48f765..62c811302 100644
--- a/notebooks/example.ipynb
+++ b/notebooks/example.ipynb
@@ -18,7 +18,7 @@
"metadata": {},
"outputs": [],
"source": [
- "import uwtools.api.config as config"
+ "from uwtools.api import config"
]
},
{
@@ -34,28 +34,171 @@
"execution_count": 2,
"id": "71957053-4264-42d5-a4e7-43b50a0ed4e4",
"metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "values:\n",
+ " date: 20240105\n",
+ " greeting: Good Night\n",
+ " recipient: Moon\n",
+ " repeat: 2\n"
+ ]
+ }
+ ],
+ "source": [
+ "# An example Python dictionary\n",
+ "dictionary = {\"values\":{\"date\":20240105, \"greeting\":\"Good Night\", \"recipient\":\"Moon\", \"repeat\":2}}\n",
+ "\n",
+ "# Build a YAMLconfig object from the dictionary\n",
+ "config_yaml = config.get_yaml_config(dictionary)\n",
+ "print(config_yaml)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dd7bcc1a-6402-42f8-884b-b22ebe1f04f5",
+ "metadata": {},
+ "source": [
+ "## Example 2: Rendering a template with uwtools\n",
+ "Next, let's look at using the `template` tool to render a Jinja2 template."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "bb672e22-d206-4d65-9dfd-b4b480d0937b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from uwtools.api import template"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9328416e-b1b8-4062-9c56-5b37440cbc4a",
+ "metadata": {},
+ "source": [
+ "We have a Jinja2 template file in `fixtures/user_config.yaml` that looks like this:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "67ebded6-474e-465d-81f2-ca8f76b89f54",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: {{ first }} {{ last }}\n",
+ " favorite_food: {{ food }}\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/user_config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8f29f1dc-a201-4356-a729-7b40565c8aa4",
+ "metadata": {},
+ "source": [
+ "We can use another yaml file that contains the values we want to add to the template to complete it:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "5df2f19a-6cb2-4c67-8fdf-2a5aa563b4e5",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "first: John\n",
+ "last: Doe\n",
+ "food: burritos"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/user_values.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cd2999db-c1e7-4ae3-9169-b807b63712e7",
+ "metadata": {},
+ "source": [
+ "Using `template.render` we can render the `user_config.yaml` file using the values supplied by the `user_values.yaml` to create a complete and ready to use config file."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "ce7f9265-4536-4163-9e2f-53aaa6ff0c63",
+ "metadata": {},
"outputs": [
{
"data": {
"text/plain": [
- "values:\n",
- " date: 20240105\n",
- " greeting: Good Night\n",
- " recipient: Moon\n",
- " repeat: 2"
+ "'user:\\n name: John Doe\\n favorite_food: burritos'"
]
},
- "execution_count": 2,
+ "execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
- "# An example Python dictionary\n",
- "dictionary = {\"values\":{\"date\":20240105, \"greeting\":\"Good Night\", \"recipient\":\"Moon\", \"repeat\":2}}\n",
+ "# path of Jinja2 template to update\n",
+ "source = \"./fixtures/user_config.yaml\"\n",
"\n",
- "# Build a YAMLconfig object from the dictionary\n",
- "config.get_yaml_config(dictionary)"
+ "# values to add\n",
+ "vals = \"./fixtures/user_values.yaml\"\n",
+ "\n",
+ "# destination of the rendered file\n",
+ "target = \"./fixtures/rendered_config.yaml\"\n",
+ "\n",
+ "# render the template \n",
+ "print(template.render(values_src=vals, values_format=\"yaml\", input_file=source, output_file=target))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f5d0b35f-d894-4758-a462-81ffa88760c0",
+ "metadata": {},
+ "source": [
+ "Let's take a look at the rendered file:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "9fe7687f-5c6c-44fc-aaf7-92711ce97457",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: John Doe\n",
+ " favorite_food: burritos\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/rendered_config.yaml"
]
}
],
diff --git a/notebooks/fixtures/user_config.yaml b/notebooks/fixtures/user_config.yaml
new file mode 100644
index 000000000..78ddc7977
--- /dev/null
+++ b/notebooks/fixtures/user_config.yaml
@@ -0,0 +1,3 @@
+user:
+ name: {{ first }} {{ last }}
+ favorite_food: {{ food }}
diff --git a/notebooks/fixtures/user_values.yaml b/notebooks/fixtures/user_values.yaml
new file mode 100644
index 000000000..5ae0cc34e
--- /dev/null
+++ b/notebooks/fixtures/user_values.yaml
@@ -0,0 +1,3 @@
+first: John
+last: Doe
+food: burritos
diff --git a/notebooks/install-deps b/notebooks/install-deps
new file mode 100644
index 000000000..84dca4d86
--- /dev/null
+++ b/notebooks/install-deps
@@ -0,0 +1 @@
+conda install -q -y nb_conda_kernels testbook
diff --git a/notebooks/pyproject.toml b/notebooks/pyproject.toml
new file mode 100644
index 000000000..0ce02b608
--- /dev/null
+++ b/notebooks/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.pytest.ini_options]
+filterwarnings = ["ignore::DeprecationWarning"]
diff --git a/notebooks/tests/test_example.py b/notebooks/tests/test_example.py
new file mode 100644
index 000000000..13e9ff708
--- /dev/null
+++ b/notebooks/tests/test_example.py
@@ -0,0 +1,52 @@
+import os
+
+import yaml
+from testbook import testbook
+
+# Run all cells of the example notebook.
+@testbook("./example.ipynb", execute=True)
+def test_get_yaml_config(tb):
+
+ # Check output text of the cell that prints the YAMLconfig object.
+ assert (
+ tb.cell_output_text(3)
+ == "values:\n date: 20240105\n greeting: Good Night\n recipient: Moon\n repeat: 2"
+ )
+
+ # Extract the config_yaml variable from the notebook and test its values.
+ nb_yaml = tb.ref("config_yaml")
+ assert nb_yaml["values"] == {
+ "date": 20240105,
+ "greeting": "Good Night",
+ "recipient": "Moon",
+ "repeat": 2,
+ }
+
+
+def test_template_render():
+ # Remove the rendered file if it exists.
+ rendered_path = "./fixtures/rendered_config.yaml"
+ if os.path.exists(rendered_path):
+ os.remove(rendered_path)
+
+ # Run all cells of the example notebook.
+ with testbook("./example.ipynb", execute=True) as tb:
+
+ # Check output text of cells with %%bash cell magics.
+ assert (
+ tb.cell_output_text(7)
+ == "user:\n name: {{ first }} {{ last }}\n favorite_food: {{ food }}"
+ )
+ assert tb.cell_output_text(9) == "first: John\nlast: Doe\nfood: burritos"
+ assert tb.cell_output_text(13) == "user:\n name: John Doe\n favorite_food: burritos"
+
+ # Check that the rendered file was created in the correct directory.
+ assert os.path.exists(rendered_path)
+
+ # Check the contents of the rendered file.
+ with open(rendered_path, "r") as f:
+ user_config = yaml.safe_load(f)
+ assert user_config["user"] == {"name": "John Doe", "favorite_food": "burritos"}
+
+ # Clean up the temporary file after the test is run.
+ os.remove(rendered_path)
From 0fdd366a318449075b67f65ccb71009780bb074b Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Mon, 22 Jul 2024 16:12:15 -0600
Subject: [PATCH 03/24] UW-645 Automate the tests for Jupyter notebooks using
GitHub Actions (#538)
---
.github/scripts/test-nb.sh | 7 +++++++
.github/workflows/test.yaml | 2 ++
notebooks/install-deps | 2 +-
3 files changed, 10 insertions(+), 1 deletion(-)
create mode 100644 .github/scripts/test-nb.sh
diff --git a/.github/scripts/test-nb.sh b/.github/scripts/test-nb.sh
new file mode 100644
index 000000000..e44216090
--- /dev/null
+++ b/.github/scripts/test-nb.sh
@@ -0,0 +1,7 @@
+set -ae
+source $(dirname ${BASH_SOURCE[0]})/common.sh
+ci_conda_activate
+conda install -c ufs-community -c conda-forge --override-channels --repodata-fn repodata.json uwtools
+cd notebooks
+source install-deps
+make test-nb
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index cd94bd0a9..97eeff098 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -23,3 +23,5 @@ jobs:
run: .github/scripts/format-check.sh
- name: Test
run: .github/scripts/test.sh
+ - name: Test Notebooks
+ run: .github/scripts/test-nb.sh
diff --git a/notebooks/install-deps b/notebooks/install-deps
index 84dca4d86..4f1226968 100644
--- a/notebooks/install-deps
+++ b/notebooks/install-deps
@@ -1 +1 @@
-conda install -q -y nb_conda_kernels testbook
+conda install -q -y jupyterlab pytest testbook
From 2733a1062b8f37e82aca493784ef99816c3b72eb Mon Sep 17 00:00:00 2001
From: lexbuko22 <119462215+lexbuko22@users.noreply.github.com>
Date: Fri, 26 Jul 2024 08:58:47 -0600
Subject: [PATCH 04/24] Updated readme with binder links (#535)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d7a3b5c10..8458a2cdd 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-[![pkgpage](https://anaconda.org/ufs-community/uwtools/badges/version.svg)](https://anaconda.org/ufs-community/uwtools) [![pkgfiles](https://anaconda.org/ufs-community/uwtools/badges/latest_release_date.svg)](https://anaconda.org/ufs-community/uwtools/files) [![docs](https://readthedocs.org/projects/uwtools/badge/?version=main)](https://uwtools.readthedocs.io/en/main/?badge=main)
+[![pkgpage](https://anaconda.org/ufs-community/uwtools/badges/version.svg)](https://anaconda.org/ufs-community/uwtools) [![pkgfiles](https://anaconda.org/ufs-community/uwtools/badges/latest_release_date.svg)](https://anaconda.org/ufs-community/uwtools/files) [![docs](https://readthedocs.org/projects/uwtools/badge/?version=main)](https://uwtools.readthedocs.io/en/main/?badge=main) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ufs-community/uwtools/notebooks?labpath=notebooks%2Fexample.ipynb)
# uwtools
From f5977c9804e375afb01b5b03d5154da0d3eb5ef9 Mon Sep 17 00:00:00 2001
From: lexbuko22 <119462215+lexbuko22@users.noreply.github.com>
Date: Fri, 26 Jul 2024 09:10:45 -0600
Subject: [PATCH 05/24] Added binder link to user guide (#544)
---
docs/sections/user_guide/index.rst | 10 ++++++++++
notebooks/environment.yaml | 1 +
2 files changed, 11 insertions(+)
create mode 100644 notebooks/environment.yaml
diff --git a/docs/sections/user_guide/index.rst b/docs/sections/user_guide/index.rst
index a385a1801..eff5d3b17 100644
--- a/docs/sections/user_guide/index.rst
+++ b/docs/sections/user_guide/index.rst
@@ -5,6 +5,16 @@ User Guide
:maxdepth: 2
installation
+
+.. raw:: html
+
+
+
+.. toctree::
+ :maxdepth: 2
+
cli/index
api/index
yaml/index
diff --git a/notebooks/environment.yaml b/notebooks/environment.yaml
new file mode 100644
index 000000000..ea9d01798
--- /dev/null
+++ b/notebooks/environment.yaml
@@ -0,0 +1 @@
+uwtools
From 765fe830f96ca082e6c034ec0e0a8e278adc679d Mon Sep 17 00:00:00 2001
From: lexbuko22 <119462215+lexbuko22@users.noreply.github.com>
Date: Fri, 26 Jul 2024 10:16:07 -0600
Subject: [PATCH 06/24] Adding compare() example (#547)
---
notebooks/binder/environment.yml | 6 +
notebooks/example.ipynb | 235 +++++++++++++++++++++-
notebooks/fixtures/config_test_file_a.nml | 1 +
notebooks/fixtures/config_test_file_b.nml | 4 +
notebooks/fixtures/config_test_file_c.nml | 4 +
notebooks/fixtures/rendered_config.yaml | 3 +
notebooks/tests/test_example.py | 9 +
7 files changed, 252 insertions(+), 10 deletions(-)
create mode 100644 notebooks/binder/environment.yml
create mode 100644 notebooks/fixtures/config_test_file_a.nml
create mode 100644 notebooks/fixtures/config_test_file_b.nml
create mode 100644 notebooks/fixtures/config_test_file_c.nml
create mode 100644 notebooks/fixtures/rendered_config.yaml
diff --git a/notebooks/binder/environment.yml b/notebooks/binder/environment.yml
new file mode 100644
index 000000000..0ccdf58bd
--- /dev/null
+++ b/notebooks/binder/environment.yml
@@ -0,0 +1,6 @@
+name: default
+channels:
+ - conda-forge
+ - ufs-community
+dependencies:
+ - uwtools
diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb
index 62c811302..0f9b821bb 100644
--- a/notebooks/example.ipynb
+++ b/notebooks/example.ipynb
@@ -124,7 +124,7 @@
"text": [
"first: John\n",
"last: Doe\n",
- "food: burritos"
+ "food: burritos\n"
]
}
],
@@ -148,14 +148,13 @@
"metadata": {},
"outputs": [
{
- "data": {
- "text/plain": [
- "'user:\\n name: John Doe\\n favorite_food: burritos'"
- ]
- },
- "execution_count": 6,
- "metadata": {},
- "output_type": "execute_result"
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: John Doe\n",
+ " favorite_food: burritos\n"
+ ]
}
],
"source": [
@@ -200,6 +199,222 @@
"%%bash\n",
"cat ./fixtures/rendered_config.yaml"
]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ea5eff92",
+ "metadata": {},
+ "source": [
+ "## Example 3: Comparing two config files\n",
+ "Let's explore using the `config.compare()` method to compare two config files."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "71d27692",
+ "metadata": {},
+ "source": [
+ "We again need to start by importing the `uwtools.api.config` from the `uwtools` python package."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "7fb40514",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from uwtools.api import config\n",
+ "from uwtools.api.logging import use_uwtools_logger\n",
+ "use_uwtools_logger()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b0103f44",
+ "metadata": {},
+ "source": [
+ "Please review the [config.compare() documentation](https://uwtools.readthedocs.io/en/main/sections/user_guide/api/config.html#uwtools.api.config.compare) for full information on this function's arguments."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b121dc29",
+ "metadata": {},
+ "source": [
+ "For example, let's compare two Fortran namelist files with differences:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "8279b5c6",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&foo n=88, s=\"string\" /"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/config_test_file_a.nml"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "e3232247",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&FOO\n",
+ " S = \"string\"\n",
+ " N = 99\n",
+ "/"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/config_test_file_b.nml"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "5dcff125",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-07-18T19:27:40] INFO - fixtures/config_test_file_a.nml\n",
+ "[2024-07-18T19:27:40] INFO + fixtures/config_test_file_b.nml\n",
+ "[2024-07-18T19:27:40] INFO ---------------------------------------------------------------------\n",
+ "[2024-07-18T19:27:40] INFO foo: n: - 88 + 99\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "file_a = \"./fixtures/config_test_file_a.nml\"\n",
+ "different_file_b = \"./fixtures/config_test_file_b.nml\"\n",
+ "config.compare(file_a, different_file_b)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c16c38d0",
+ "metadata": {},
+ "source": [
+ "The `config()` method returns `False` to denote the files are different. The UW logger shows the difference, of one file containing `n = 88`, and one file containing `n = 99`.\n",
+ "\n",
+ "Now to compare two semantically equivalent files:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "370149a3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&foo n=88, s=\"string\" /"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/config_test_file_a.nml"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "bd444fba",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&FOO\n",
+ " S = \"string\"\n",
+ " N = 88\n",
+ "/"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat ./fixtures/config_test_file_c.nml"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "9d658b72",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-07-18T19:27:40] INFO - fixtures/config_test_file_a.nml\n",
+ "[2024-07-18T19:27:40] INFO + fixtures/config_test_file_c.nml\n",
+ "[2024-07-18T19:27:40] INFO ---------------------------------------------------------------------\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 14,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "file_a = \"./fixtures/config_test_file_a.nml\"\n",
+ "identical_file_c = \"./fixtures/config_test_file_c.nml\"\n",
+ "config.compare(file_a, identical_file_c)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b84db243",
+ "metadata": {},
+ "source": [
+ "The `config()` method returns `True` to denote the files are semantically equivalent."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5b39d871",
+ "metadata": {},
+ "source": []
}
],
"metadata": {
@@ -218,7 +433,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.3"
+ "version": "3.12.4"
}
},
"nbformat": 4,
diff --git a/notebooks/fixtures/config_test_file_a.nml b/notebooks/fixtures/config_test_file_a.nml
new file mode 100644
index 000000000..42e15827f
--- /dev/null
+++ b/notebooks/fixtures/config_test_file_a.nml
@@ -0,0 +1 @@
+&foo n=88, s="string" /
diff --git a/notebooks/fixtures/config_test_file_b.nml b/notebooks/fixtures/config_test_file_b.nml
new file mode 100644
index 000000000..ecb87d942
--- /dev/null
+++ b/notebooks/fixtures/config_test_file_b.nml
@@ -0,0 +1,4 @@
+&FOO
+ S = "string"
+ N = 99
+/
diff --git a/notebooks/fixtures/config_test_file_c.nml b/notebooks/fixtures/config_test_file_c.nml
new file mode 100644
index 000000000..e6603e87d
--- /dev/null
+++ b/notebooks/fixtures/config_test_file_c.nml
@@ -0,0 +1,4 @@
+&FOO
+ S = "string"
+ N = 88
+/
diff --git a/notebooks/fixtures/rendered_config.yaml b/notebooks/fixtures/rendered_config.yaml
new file mode 100644
index 000000000..2840700bf
--- /dev/null
+++ b/notebooks/fixtures/rendered_config.yaml
@@ -0,0 +1,3 @@
+user:
+ name: John Doe
+ favorite_food: burritos
diff --git a/notebooks/tests/test_example.py b/notebooks/tests/test_example.py
index 13e9ff708..ea3113257 100644
--- a/notebooks/tests/test_example.py
+++ b/notebooks/tests/test_example.py
@@ -50,3 +50,12 @@ def test_template_render():
# Clean up the temporary file after the test is run.
os.remove(rendered_path)
+
+
+# Run all cells of the example notebook.
+@testbook("./example.ipynb", execute=True)
+def test_compare(tb):
+
+ # Check output text of the cell prints the correct result
+ assert 'False' in tb.cell_output_text(21)
+ assert 'True' in tb.cell_output_text(25)
From e1b37fafa5c9ce5a8c8b803506be5c3e54722457 Mon Sep 17 00:00:00 2001
From: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
Date: Mon, 29 Jul 2024 10:12:29 -0600
Subject: [PATCH 07/24] Move binder/environment.yml (#549)
---
{notebooks/binder => binder}/environment.yml | 0
notebooks/environment.yaml | 1 -
2 files changed, 1 deletion(-)
rename {notebooks/binder => binder}/environment.yml (100%)
delete mode 100644 notebooks/environment.yaml
diff --git a/notebooks/binder/environment.yml b/binder/environment.yml
similarity index 100%
rename from notebooks/binder/environment.yml
rename to binder/environment.yml
diff --git a/notebooks/environment.yaml b/notebooks/environment.yaml
deleted file mode 100644
index ea9d01798..000000000
--- a/notebooks/environment.yaml
+++ /dev/null
@@ -1 +0,0 @@
-uwtools
From 452139a9fc61f78ad1b8933b82f6b3ea92a08fe7 Mon Sep 17 00:00:00 2001
From: Paul Madden
Date: Sat, 17 Aug 2024 17:55:22 +0000
Subject: [PATCH 08/24] Add formatting and linting
---
notebooks/Makefile | 13 +++++++++++--
notebooks/pyproject.toml | 19 +++++++++++++++++++
notebooks/tests/test_example.py | 7 ++++---
3 files changed, 34 insertions(+), 5 deletions(-)
diff --git a/notebooks/Makefile b/notebooks/Makefile
index f6c162470..3a0635579 100644
--- a/notebooks/Makefile
+++ b/notebooks/Makefile
@@ -1,9 +1,18 @@
-TARGETS = test-nb
+TARGETS = format lint test-nb unittest
.PHONY: $(TARGETS)
all:
$(error Valid targets are: $(TARGETS))
-test-nb:
+format:
+ black tests
+ isort tests
+
+lint:
+ pylint tests
+
+test-nb: lint unittest
+
+unittest:
pytest tests
diff --git a/notebooks/pyproject.toml b/notebooks/pyproject.toml
index 0ce02b608..c3096a235 100644
--- a/notebooks/pyproject.toml
+++ b/notebooks/pyproject.toml
@@ -1,2 +1,21 @@
+[tool.black]
+line-length = 100
+
+[tool.isort]
+line_length = 100
+profile = "black"
+
+[tool.pylint.main]
+recursive = true
+
+[tool.pylint."messages control"]
+disable = [
+ "missing-function-docstring",
+ "missing-module-docstring",
+]
+enable = [
+ "useless-suppression",
+]
+
[tool.pytest.ini_options]
filterwarnings = ["ignore::DeprecationWarning"]
diff --git a/notebooks/tests/test_example.py b/notebooks/tests/test_example.py
index ea3113257..d70fe05a0 100644
--- a/notebooks/tests/test_example.py
+++ b/notebooks/tests/test_example.py
@@ -3,6 +3,7 @@
import yaml
from testbook import testbook
+
# Run all cells of the example notebook.
@testbook("./example.ipynb", execute=True)
def test_get_yaml_config(tb):
@@ -44,7 +45,7 @@ def test_template_render():
assert os.path.exists(rendered_path)
# Check the contents of the rendered file.
- with open(rendered_path, "r") as f:
+ with open(rendered_path, "r", encoding="utf-8") as f:
user_config = yaml.safe_load(f)
assert user_config["user"] == {"name": "John Doe", "favorite_food": "burritos"}
@@ -57,5 +58,5 @@ def test_template_render():
def test_compare(tb):
# Check output text of the cell prints the correct result
- assert 'False' in tb.cell_output_text(21)
- assert 'True' in tb.cell_output_text(25)
+ assert "False" in tb.cell_output_text(21)
+ assert "True" in tb.cell_output_text(25)
From 2aadaa3db10dab5ac17cf72079db4e5d88c56cf4 Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Tue, 20 Aug 2024 08:51:05 -0600
Subject: [PATCH 09/24] UW-651 Jupyter Notebook: Template Mode (#581)
---
docs/sections/user_guide/api/template.rst | 3 +
docs/sections/user_guide/index.rst | 13 +
notebooks/example.ipynb | 72 +--
.../ex2-config.yaml} | 0
.../ex2-rendered-config.yaml} | 0
.../ex2-values.yaml} | 0
.../ex3-config-test-file-a.nml} | 0
.../ex3-config-test-file-b.nml} | 0
.../ex3-config-test-file-c.nml} | 0
.../fixtures/template/render-complete-1.yaml | 3 +
.../fixtures/template/render-complete-2.yaml | 3 +
.../fixtures/template/render-template.yaml | 3 +
.../fixtures/template/render-values.yaml | 3 +
.../fixtures/template/translate-complete.yaml | 3 +
.../fixtures/template/translate-template.yaml | 3 +
notebooks/template.ipynb | 511 ++++++++++++++++++
notebooks/tests/test_example.py | 8 +-
notebooks/tests/test_template.py | 75 +++
18 files changed, 654 insertions(+), 46 deletions(-)
rename notebooks/fixtures/{user_config.yaml => example/ex2-config.yaml} (100%)
rename notebooks/fixtures/{rendered_config.yaml => example/ex2-rendered-config.yaml} (100%)
rename notebooks/fixtures/{user_values.yaml => example/ex2-values.yaml} (100%)
rename notebooks/fixtures/{config_test_file_a.nml => example/ex3-config-test-file-a.nml} (100%)
rename notebooks/fixtures/{config_test_file_b.nml => example/ex3-config-test-file-b.nml} (100%)
rename notebooks/fixtures/{config_test_file_c.nml => example/ex3-config-test-file-c.nml} (100%)
create mode 100644 notebooks/fixtures/template/render-complete-1.yaml
create mode 100644 notebooks/fixtures/template/render-complete-2.yaml
create mode 100644 notebooks/fixtures/template/render-template.yaml
create mode 100644 notebooks/fixtures/template/render-values.yaml
create mode 100644 notebooks/fixtures/template/translate-complete.yaml
create mode 100644 notebooks/fixtures/template/translate-template.yaml
create mode 100644 notebooks/template.ipynb
create mode 100644 notebooks/tests/test_template.py
diff --git a/docs/sections/user_guide/api/template.rst b/docs/sections/user_guide/api/template.rst
index ac89e7002..b75de604f 100644
--- a/docs/sections/user_guide/api/template.rst
+++ b/docs/sections/user_guide/api/template.rst
@@ -1,5 +1,8 @@
``uwtools.api.template``
========================
+.. image:: https://mybinder.org/badge_logo.svg
+ :target: https://mybinder.org/v2/gh/ufs-community/uwtools/notebooks?labpath=notebooks%2Ftemplate.ipynb
+
.. automodule:: uwtools.api.template
:members:
diff --git a/docs/sections/user_guide/index.rst b/docs/sections/user_guide/index.rst
index eff5d3b17..612a08f4d 100644
--- a/docs/sections/user_guide/index.rst
+++ b/docs/sections/user_guide/index.rst
@@ -17,4 +17,17 @@ User Guide
cli/index
api/index
+
+.. raw:: html
+
+
+ - API Jupyter Notebooks
+
+
+
+.. toctree::
+ :maxdepth: 2
+
yaml/index
diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb
index 0f9b821bb..167a62998 100644
--- a/notebooks/example.ipynb
+++ b/notebooks/example.ipynb
@@ -48,10 +48,8 @@
}
],
"source": [
- "# An example Python dictionary\n",
"dictionary = {\"values\":{\"date\":20240105, \"greeting\":\"Good Night\", \"recipient\":\"Moon\", \"repeat\":2}}\n",
"\n",
- "# Build a YAMLconfig object from the dictionary\n",
"config_yaml = config.get_yaml_config(dictionary)\n",
"print(config_yaml)"
]
@@ -80,7 +78,7 @@
"id": "9328416e-b1b8-4062-9c56-5b37440cbc4a",
"metadata": {},
"source": [
- "We have a Jinja2 template file in `fixtures/user_config.yaml` that looks like this:"
+ "We have a Jinja2 template file in `fixtures/example/ex2-config.yaml` that looks like this:"
]
},
{
@@ -101,7 +99,7 @@
],
"source": [
"%%bash\n",
- "cat ./fixtures/user_config.yaml"
+ "cat fixtures/example/ex2-config.yaml"
]
},
{
@@ -130,7 +128,7 @@
],
"source": [
"%%bash\n",
- "cat ./fixtures/user_values.yaml"
+ "cat fixtures/example/ex2-values.yaml"
]
},
{
@@ -138,7 +136,7 @@
"id": "cd2999db-c1e7-4ae3-9169-b807b63712e7",
"metadata": {},
"source": [
- "Using `template.render` we can render the `user_config.yaml` file using the values supplied by the `user_values.yaml` to create a complete and ready to use config file."
+ "Using `template.render` we can render the `ex2-config.yaml` file using the values supplied by the `ex2-values.yaml` to create a complete and ready to use config file."
]
},
{
@@ -158,16 +156,12 @@
}
],
"source": [
- "# path of Jinja2 template to update\n",
- "source = \"./fixtures/user_config.yaml\"\n",
+ "source = \"fixtures/example/ex2-config.yaml\"\n",
"\n",
- "# values to add\n",
- "vals = \"./fixtures/user_values.yaml\"\n",
+ "vals = \"fixtures/example/ex2-values.yaml\"\n",
"\n",
- "# destination of the rendered file\n",
- "target = \"./fixtures/rendered_config.yaml\"\n",
+ "target = \"fixtures/example/ex2-rendered-config.yaml\"\n",
"\n",
- "# render the template \n",
"print(template.render(values_src=vals, values_format=\"yaml\", input_file=source, output_file=target))"
]
},
@@ -197,7 +191,7 @@
],
"source": [
"%%bash\n",
- "cat ./fixtures/rendered_config.yaml"
+ "cat fixtures/example/ex2-rendered-config.yaml"
]
},
{
@@ -255,13 +249,13 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "&foo n=88, s=\"string\" /"
+ "&foo n=88, s=\"string\" /\n"
]
}
],
"source": [
"%%bash\n",
- "cat ./fixtures/config_test_file_a.nml"
+ "cat fixtures/example/ex3-config-test-file-a.nml"
]
},
{
@@ -277,13 +271,13 @@
"&FOO\n",
" S = \"string\"\n",
" N = 99\n",
- "/"
+ "/\n"
]
}
],
"source": [
"%%bash\n",
- "cat ./fixtures/config_test_file_b.nml"
+ "cat fixtures/example/ex3-config-test-file-b.nml"
]
},
{
@@ -296,10 +290,10 @@
"name": "stderr",
"output_type": "stream",
"text": [
- "[2024-07-18T19:27:40] INFO - fixtures/config_test_file_a.nml\n",
- "[2024-07-18T19:27:40] INFO + fixtures/config_test_file_b.nml\n",
- "[2024-07-18T19:27:40] INFO ---------------------------------------------------------------------\n",
- "[2024-07-18T19:27:40] INFO foo: n: - 88 + 99\n"
+ "[2024-08-18T10:24:32] INFO - fixtures/example/ex3-config-test-file-a.nml\n",
+ "[2024-08-18T10:24:32] INFO + fixtures/example/ex3-config-test-file-b.nml\n",
+ "[2024-08-18T10:24:32] INFO ---------------------------------------------------------------------\n",
+ "[2024-08-18T10:24:32] INFO foo: n: - 88 + 99\n"
]
},
{
@@ -314,8 +308,8 @@
}
],
"source": [
- "file_a = \"./fixtures/config_test_file_a.nml\"\n",
- "different_file_b = \"./fixtures/config_test_file_b.nml\"\n",
+ "file_a = \"fixtures/example/ex3-config-test-file-a.nml\"\n",
+ "different_file_b = \"fixtures/example/ex3-config-test-file-b.nml\"\n",
"config.compare(file_a, different_file_b)"
]
},
@@ -339,13 +333,13 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "&foo n=88, s=\"string\" /"
+ "&foo n=88, s=\"string\" /\n"
]
}
],
"source": [
"%%bash\n",
- "cat ./fixtures/config_test_file_a.nml"
+ "cat fixtures/example/ex3-config-test-file-a.nml"
]
},
{
@@ -361,13 +355,13 @@
"&FOO\n",
" S = \"string\"\n",
" N = 88\n",
- "/"
+ "/\n"
]
}
],
"source": [
"%%bash\n",
- "cat ./fixtures/config_test_file_c.nml"
+ "cat fixtures/example/ex3-config-test-file-c.nml"
]
},
{
@@ -380,9 +374,9 @@
"name": "stderr",
"output_type": "stream",
"text": [
- "[2024-07-18T19:27:40] INFO - fixtures/config_test_file_a.nml\n",
- "[2024-07-18T19:27:40] INFO + fixtures/config_test_file_c.nml\n",
- "[2024-07-18T19:27:40] INFO ---------------------------------------------------------------------\n"
+ "[2024-08-18T10:24:35] INFO - fixtures/example/ex3-config-test-file-a.nml\n",
+ "[2024-08-18T10:24:35] INFO + fixtures/example/ex3-config-test-file-c.nml\n",
+ "[2024-08-18T10:24:35] INFO ---------------------------------------------------------------------\n"
]
},
{
@@ -397,8 +391,8 @@
}
],
"source": [
- "file_a = \"./fixtures/config_test_file_a.nml\"\n",
- "identical_file_c = \"./fixtures/config_test_file_c.nml\"\n",
+ "file_a = \"fixtures/example/ex3-config-test-file-a.nml\"\n",
+ "identical_file_c = \"fixtures/example/ex3-config-test-file-c.nml\"\n",
"config.compare(file_a, identical_file_c)"
]
},
@@ -409,19 +403,13 @@
"source": [
"The `config()` method returns `True` to denote the files are semantically equivalent."
]
- },
- {
- "cell_type": "markdown",
- "id": "5b39d871",
- "metadata": {},
- "source": []
}
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3 (ipykernel)",
+ "display_name": "Python [conda env:DEV-uwtools] *",
"language": "python",
- "name": "python3"
+ "name": "conda-env-DEV-uwtools-py"
},
"language_info": {
"codemirror_mode": {
@@ -433,7 +421,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.4"
+ "version": "3.12.3"
}
},
"nbformat": 4,
diff --git a/notebooks/fixtures/user_config.yaml b/notebooks/fixtures/example/ex2-config.yaml
similarity index 100%
rename from notebooks/fixtures/user_config.yaml
rename to notebooks/fixtures/example/ex2-config.yaml
diff --git a/notebooks/fixtures/rendered_config.yaml b/notebooks/fixtures/example/ex2-rendered-config.yaml
similarity index 100%
rename from notebooks/fixtures/rendered_config.yaml
rename to notebooks/fixtures/example/ex2-rendered-config.yaml
diff --git a/notebooks/fixtures/user_values.yaml b/notebooks/fixtures/example/ex2-values.yaml
similarity index 100%
rename from notebooks/fixtures/user_values.yaml
rename to notebooks/fixtures/example/ex2-values.yaml
diff --git a/notebooks/fixtures/config_test_file_a.nml b/notebooks/fixtures/example/ex3-config-test-file-a.nml
similarity index 100%
rename from notebooks/fixtures/config_test_file_a.nml
rename to notebooks/fixtures/example/ex3-config-test-file-a.nml
diff --git a/notebooks/fixtures/config_test_file_b.nml b/notebooks/fixtures/example/ex3-config-test-file-b.nml
similarity index 100%
rename from notebooks/fixtures/config_test_file_b.nml
rename to notebooks/fixtures/example/ex3-config-test-file-b.nml
diff --git a/notebooks/fixtures/config_test_file_c.nml b/notebooks/fixtures/example/ex3-config-test-file-c.nml
similarity index 100%
rename from notebooks/fixtures/config_test_file_c.nml
rename to notebooks/fixtures/example/ex3-config-test-file-c.nml
diff --git a/notebooks/fixtures/template/render-complete-1.yaml b/notebooks/fixtures/template/render-complete-1.yaml
new file mode 100644
index 000000000..2840700bf
--- /dev/null
+++ b/notebooks/fixtures/template/render-complete-1.yaml
@@ -0,0 +1,3 @@
+user:
+ name: John Doe
+ favorite_food: burritos
diff --git a/notebooks/fixtures/template/render-complete-2.yaml b/notebooks/fixtures/template/render-complete-2.yaml
new file mode 100644
index 000000000..df6d5f0bd
--- /dev/null
+++ b/notebooks/fixtures/template/render-complete-2.yaml
@@ -0,0 +1,3 @@
+user:
+ name: Jane Doe
+ favorite_food: tamales
diff --git a/notebooks/fixtures/template/render-template.yaml b/notebooks/fixtures/template/render-template.yaml
new file mode 100644
index 000000000..78ddc7977
--- /dev/null
+++ b/notebooks/fixtures/template/render-template.yaml
@@ -0,0 +1,3 @@
+user:
+ name: {{ first }} {{ last }}
+ favorite_food: {{ food }}
diff --git a/notebooks/fixtures/template/render-values.yaml b/notebooks/fixtures/template/render-values.yaml
new file mode 100644
index 000000000..5ae0cc34e
--- /dev/null
+++ b/notebooks/fixtures/template/render-values.yaml
@@ -0,0 +1,3 @@
+first: John
+last: Doe
+food: burritos
diff --git a/notebooks/fixtures/template/translate-complete.yaml b/notebooks/fixtures/template/translate-complete.yaml
new file mode 100644
index 000000000..7841b3c8c
--- /dev/null
+++ b/notebooks/fixtures/template/translate-complete.yaml
@@ -0,0 +1,3 @@
+flowers:
+ roses: {{ color1 }}
+ violets: {{ color2 }}
diff --git a/notebooks/fixtures/template/translate-template.yaml b/notebooks/fixtures/template/translate-template.yaml
new file mode 100644
index 000000000..0eab2bc00
--- /dev/null
+++ b/notebooks/fixtures/template/translate-template.yaml
@@ -0,0 +1,3 @@
+flowers:
+ roses: @[color1]
+ violets: @[color2]
diff --git a/notebooks/template.ipynb b/notebooks/template.ipynb
new file mode 100644
index 000000000..e0e9a2e9e
--- /dev/null
+++ b/notebooks/template.ipynb
@@ -0,0 +1,511 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "50994576-4783-4e8c-a6b0-21b1f258685d",
+ "metadata": {},
+ "source": [
+ "# Template Tool\n",
+ "\n",
+ "The `uwtools` API's `template` module provides functions to render Jinja2 templates and to translate atparse templates to Jinja2.\n",
+ "\n",
+ "For more information, please see the uwtools.api.template Read the Docs page."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "cbec4cc0-369e-41ff-a8a6-8a2699cb5759",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from uwtools.api import template\n",
+ "from pathlib import Path"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "017c777a-1ca0-4fef-873f-89924e209da8",
+ "metadata": {},
+ "source": [
+ "## render\n",
+ "\n",
+ "`template.render()` renders a Jinja2 template using values provided by the specified values source."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "54e88f1b-0b9f-4011-b070-df107f928cf9",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function render in module uwtools.api.template:\n",
+ "\n",
+ "render(values_src: Union[dict, pathlib.Path, str, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, output_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False, stdin_ok: bool = False) -> str\n",
+ " Render a Jinja2 template to a file, based on specified values.\n",
+ "\n",
+ " Primary values used to render the template are taken from the specified file. The format of the\n",
+ " values source will be deduced from the filename extension, if possible. This can be overridden\n",
+ " via the ``values_format`` argument. A ``dict`` object may alternatively be provided as the\n",
+ " primary values source. If no input file is specified, ``stdin`` is read. If no output file is\n",
+ " specified, ``stdout`` is written to.\n",
+ "\n",
+ " :param values_src: Source of values to render the template.\n",
+ " :param values_format: Format of values when sourced from file.\n",
+ " :param input_file: Raw input template file (``None`` => read ``stdin``).\n",
+ " :param output_file: Rendered template output file (``None`` => write to ``stdout``).\n",
+ " :param overrides: Supplemental override values.\n",
+ " :param env: Supplement values with environment variables?\n",
+ " :param searchpath: Paths to search for extra templates.\n",
+ " :param values_needed: Just report variables needed to render the template?\n",
+ " :param dry_run: Run in dry-run mode?\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: The rendered template string.\n",
+ " :raises: UWTemplateRenderError if template could not be rendered.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(template.render)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28a3415f-3f0e-42c7-8be2-bc94057e8510",
+ "metadata": {},
+ "source": [
+ "Consider the following template, to be rendered as YAML data:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "91bd29fd-77ba-4ea2-946f-cd7a2d9301f1",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: {{ first }} {{ last }}\n",
+ " favorite_food: {{ food }}\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/template/render-template.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d335aec6-5fcd-4df2-ae2e-8afa1a510683",
+ "metadata": {},
+ "source": [
+ "The `values_needed` parameter can be used to display which values are needed to complete the template. A logger needs to be initialized for the log of the missing values to be displayed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "040eceb1-0821-4e82-825a-5be18f06397d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-20T08:10:58] INFO Value(s) needed to render this template are:\n",
+ "[2024-08-20T08:10:58] INFO first\n",
+ "[2024-08-20T08:10:58] INFO food\n",
+ "[2024-08-20T08:10:58] INFO last\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: {{ first }} {{ last }}\n",
+ " favorite_food: {{ food }}\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "import uwtools.logging\n",
+ "uwtools.logging.setup_logging(verbose=False)\n",
+ "\n",
+ "print(\n",
+ " template.render(\n",
+ " input_file='fixtures/template/render-template.yaml',\n",
+ " values_needed=True\n",
+ " )\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6d6913b9-a375-447a-b729-566afe84f694",
+ "metadata": {},
+ "source": [
+ "The log messages indicate that values are needed for keys `first`, `food`, and `last`. These values can be sourced from a Python dictionary or from a file. The following file provides the needed values:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "7f794c66-8840-419a-adf5-20efddb85708",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "first: John\n",
+ "last: Doe\n",
+ "food: burritos\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/template/render-values.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9c809a20-b09d-438a-a1da-3cb9986ce9fa",
+ "metadata": {},
+ "source": [
+ "With these values, we can render the template to a file. When the source of values is a file, its path can be given either as a string or a Path object. If it has an unrecognized (or no) extension, its format can be specified with `values_format`. The rendered template can be written to a file specified with `output_file`; otherwise, it will be written to `stdout`. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "834b7a40-293e-4d35-81e8-121eed4cf8f2",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: John Doe\n",
+ " favorite_food: burritos\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\n",
+ " template.render(\n",
+ " values_src=Path('fixtures/template/render-values.yaml'),\n",
+ " values_format='yaml',\n",
+ " input_file='fixtures/template/render-template.yaml',\n",
+ " output_file='fixtures/template/render-complete-1.yaml'\n",
+ " )\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c45b0ac1-23d6-4d25-a691-7bc4f482429e",
+ "metadata": {},
+ "source": [
+ "Values can be selectively overridden with a dictionary passed via the optional `overrides` argument."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "55eec4f4-4f91-4618-8382-78061907bd2a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: Jane Doe\n",
+ " favorite_food: tamales\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\n",
+ " template.render(\n",
+ " values_src=Path('fixtures/template/render-values.yaml'),\n",
+ " values_format='yaml',\n",
+ " input_file='fixtures/template/render-template.yaml',\n",
+ " output_file='fixtures/template/render-complete-2.yaml',\n",
+ " overrides={'first':'Jane', 'food':'tamales'}\n",
+ " )\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "861e1e96-58b6-4537-bc7d-7986b450e774",
+ "metadata": {},
+ "source": [
+ "Let's take a look at the two newly rendered files."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "8601d4d9-5e53-44b7-880c-666ab810d8b8",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: John Doe\n",
+ " favorite_food: burritos\n",
+ "---------------------------------------\n",
+ "user:\n",
+ " name: Jane Doe\n",
+ " favorite_food: tamales\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/template/render-complete-1.yaml\n",
+ "echo ---------------------------------------\n",
+ "cat fixtures/template/render-complete-2.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "58cbbfbf-b47d-485d-9ef3-80b067316d11",
+ "metadata": {},
+ "source": [
+ "## render_to_str\n",
+ "\n",
+ "`template.render_to_str()` is identical to `template.render()` except that it does not accept an `output_file` parameter: It returns the rendered template as a string and does not write to a file or to `stdout`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "ff8b80b2-590c-476f-94f7-37c4f34932f7",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function render_to_str in module uwtools.api.template:\n",
+ "\n",
+ "render_to_str(values_src: Union[dict, pathlib.Path, str, NoneType] = None, values_format: Optional[str] = None, input_file: Union[str, pathlib.Path, NoneType] = None, overrides: Optional[dict[str, str]] = None, env: bool = False, searchpath: Optional[list[str]] = None, values_needed: bool = False, dry_run: bool = False) -> str\n",
+ " Render a Jinja2 template to a string, based on specified values.\n",
+ "\n",
+ " See ``render()`` for details on arguments, etc.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(template.render_to_str)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "65905cf1-679d-46ef-96e6-23e0c952947c",
+ "metadata": {},
+ "source": [
+ "We can see the resulting string using the same template and values from the first `template.render()` example."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "1f2cec30-0761-42f4-85fc-05593e215b23",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "user:\n",
+ " name: John Doe\n",
+ " favorite_food: burritos\n"
+ ]
+ }
+ ],
+ "source": [
+ "result = template.render_to_str(\n",
+ " values_src=Path('fixtures/template/render-values.yaml'),\n",
+ " values_format='yaml',\n",
+ " input_file='fixtures/template/render-template.yaml'\n",
+ ")\n",
+ "print(result)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fe45be03-c1aa-4c50-a21b-3f35180569b4",
+ "metadata": {},
+ "source": [
+ "For more examples, please refer to the render section above.\n",
+ "\n",
+ "## translate\n",
+ "\n",
+ "This function can be used to translate atparse templates into Jinja2 templates by replacing `@[]` tokens with their corresponding `{{}}` Jinja2 equivalents. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "2ddcefac-030d-415c-a97f-eab9e176e811",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function translate in module uwtools.api.template:\n",
+ "\n",
+ "translate(input_file: Union[str, pathlib.Path, NoneType] = None, output_file: Union[str, pathlib.Path, NoneType] = None, dry_run: bool = False, stdin_ok: bool = False) -> bool\n",
+ " Translate an atparse template to a Jinja2 template.\n",
+ "\n",
+ " ``@[]`` tokens are replaced with Jinja2 ``{{}}`` equivalents. If no input file is specified,\n",
+ " ``stdin`` is read. If no output file is specified, ``stdout`` is written to. In ``dry_run``\n",
+ " mode, output is written to ``stderr``.\n",
+ "\n",
+ " :param input_file: Path to atparse file (``None`` => read ``stdin``).\n",
+ " :param output_file: Path to the file to write the converted template to.\n",
+ " :param dry_run: Run in dry-run mode?\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: ``True``.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(template.translate)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1340097f-5ace-482d-bd13-01b426e768a1",
+ "metadata": {},
+ "source": [
+ "The template tool works with atparse templates like the one shown below."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "66fbde65-2c4e-48fa-bc49-c4faec78f944",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "flowers:\n",
+ " roses: @[color1]\n",
+ " violets: @[color2]\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat 'fixtures/template/translate-template.yaml'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "62d87063-2cd0-40de-bf02-dee0ace11d5a",
+ "metadata": {},
+ "source": [
+ "We can translate this file to a Jinja2 template by passing appropriate `input_file` and `output_file` (either `str` or Path) values to `template.render()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "bab9026c-9f5a-435d-b8a2-71fa2a325109",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 13,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "template.translate(\n",
+ " input_file=Path('fixtures/template/translate-template.yaml'),\n",
+ " output_file='fixtures/template/translate-complete.yaml'\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e8712324-39a1-49d1-b2c6-dce2907b149e",
+ "metadata": {},
+ "source": [
+ "Now we have created a Jinja2 template that can be rendered using `template.render()` or `template.render_to_str()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "5f30435c-e253-4f8a-a8e7-6bdbd8be92c9",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "flowers:\n",
+ " roses: {{ color1 }}\n",
+ " violets: {{ color2 }}\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat 'fixtures/template/translate-complete.yaml'"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python [conda env:DEV-uwtools] *",
+ "language": "python",
+ "name": "conda-env-DEV-uwtools-py"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/tests/test_example.py b/notebooks/tests/test_example.py
index d70fe05a0..c573bab8e 100644
--- a/notebooks/tests/test_example.py
+++ b/notebooks/tests/test_example.py
@@ -5,7 +5,7 @@
# Run all cells of the example notebook.
-@testbook("./example.ipynb", execute=True)
+@testbook("example.ipynb", execute=True)
def test_get_yaml_config(tb):
# Check output text of the cell that prints the YAMLconfig object.
@@ -26,12 +26,12 @@ def test_get_yaml_config(tb):
def test_template_render():
# Remove the rendered file if it exists.
- rendered_path = "./fixtures/rendered_config.yaml"
+ rendered_path = "fixtures/example/ex2-rendered-config.yaml"
if os.path.exists(rendered_path):
os.remove(rendered_path)
# Run all cells of the example notebook.
- with testbook("./example.ipynb", execute=True) as tb:
+ with testbook("example.ipynb", execute=True) as tb:
# Check output text of cells with %%bash cell magics.
assert (
@@ -54,7 +54,7 @@ def test_template_render():
# Run all cells of the example notebook.
-@testbook("./example.ipynb", execute=True)
+@testbook("example.ipynb", execute=True)
def test_compare(tb):
# Check output text of the cell prints the correct result
diff --git a/notebooks/tests/test_template.py b/notebooks/tests/test_template.py
new file mode 100644
index 000000000..d0395573f
--- /dev/null
+++ b/notebooks/tests/test_template.py
@@ -0,0 +1,75 @@
+import os
+
+import yaml
+from testbook import testbook
+
+
+def test_render():
+
+ template = "fixtures/template/render-template.yaml"
+ values = "fixtures/template/render-values.yaml"
+ rendered_template1 = "fixtures/template/render-complete-1.yaml"
+ rendered_template2 = "fixtures/template/render-complete-2.yaml"
+
+ for file in rendered_template1, rendered_template2:
+ if os.path.exists(file):
+ os.remove(file)
+
+ with open(template, "r", encoding="utf-8") as f:
+ template_str = f.read().rstrip()
+ with open(values, "r", encoding="utf-8") as f:
+ values_str = f.read().rstrip()
+
+ with testbook("template.ipynb", execute=True) as tb:
+
+ with open(rendered_template1, "r", encoding="utf-8") as f:
+ rend_temp_str1 = f.read().rstrip()
+ temp_yaml1 = yaml.safe_load(rend_temp_str1)
+ assert temp_yaml1["user"] == {"name": "John Doe", "favorite_food": "burritos"}
+
+ with open(rendered_template2, "r", encoding="utf-8") as f:
+ rend_temp_str2 = f.read().rstrip()
+ temp_yaml2 = yaml.safe_load(rend_temp_str2)
+ assert temp_yaml2["user"] == {"name": "Jane Doe", "favorite_food": "tamales"}
+
+ assert tb.cell_output_text(5) == template_str
+ assert (
+ "INFO first" in tb.cell_output_text(7)
+ and "INFO food" in tb.cell_output_text(7)
+ and "INFO last" in tb.cell_output_text(7)
+ )
+ assert tb.cell_output_text(9) == values_str
+ assert tb.cell_output_text(11) == rend_temp_str1
+ assert tb.cell_output_text(13) == rend_temp_str2
+ assert rend_temp_str1 in tb.cell_output_text(15) and rend_temp_str2 in tb.cell_output_text(
+ 15
+ )
+
+
+@testbook("template.ipynb", execute=True)
+def test_render_to_str(tb):
+
+ rend_temp_str = "user:\n name: John Doe\n favorite_food: burritos"
+ assert tb.ref("result") == rend_temp_str
+ assert tb.cell_output_text(19) == rend_temp_str
+
+
+def test_translate():
+
+ atparse_template = "fixtures/template/translate-template.yaml"
+ translated_template = "fixtures/template/translate-complete.yaml"
+
+ if os.path.exists(translated_template):
+ os.remove(translated_template)
+
+ with open(atparse_template, "r", encoding="utf-8") as f:
+ atparse_str = f.read().rstrip()
+
+ with testbook("template.ipynb", execute=True) as tb:
+
+ with open(translated_template, "r", encoding="utf-8") as f:
+ translated_str = f.read().rstrip()
+
+ assert tb.cell_output_text(23) == atparse_str
+ assert tb.cell_output_text(25) == "True"
+ assert tb.cell_output_text(27) == translated_str
From a842fa310e79f0289f00c28dfbcf0f16af0fe430 Mon Sep 17 00:00:00 2001
From: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
Date: Mon, 26 Aug 2024 10:57:31 -0600
Subject: [PATCH 10/24] notebooks update (#593)
---
docs/Makefile | 2 +-
docs/conf.py | 2 +-
docs/index.rst | 15 +-
docs/sections/user_guide/api/config.rst | 1 +
docs/sections/user_guide/api/execute.rst | 6 +
docs/sections/user_guide/api/file.rst | 5 -
docs/sections/user_guide/api/fs.rst | 5 +
docs/sections/user_guide/api/index.rst | 3 +-
.../user_guide/cli/drivers/.gitignore | 1 +
.../sections/user_guide/cli/drivers/cdeps.rst | 9 +-
.../user_guide/cli/drivers/cdeps/help.out | 16 +-
.../user_guide/cli/drivers/cdeps/run-help.out | 2 +-
.../cli/drivers/cdeps/show-schema.cmd | 2 +
.../cli/drivers/cdeps/show-schema.out | 21 ++
.../user_guide/cli/drivers/chgres_cube.rst | 8 +
.../cli/drivers/chgres_cube/help.out | 4 +-
.../cli/drivers/chgres_cube/show-schema.cmd | 2 +
.../cli/drivers/chgres_cube/show-schema.out | 21 ++
.../user_guide/cli/drivers/esg_grid.rst | 8 +
.../user_guide/cli/drivers/esg_grid/help.out | 4 +-
.../cli/drivers/esg_grid/show-schema.cmd | 2 +
.../cli/drivers/esg_grid/show-schema.out | 21 ++
.../user_guide/cli/drivers/filter_topo.rst | 8 +
.../cli/drivers/filter_topo/help.out | 4 +-
.../cli/drivers/filter_topo/show-schema.cmd | 2 +
.../cli/drivers/filter_topo/show-schema.out | 21 ++
docs/sections/user_guide/cli/drivers/fv3.rst | 8 +
.../user_guide/cli/drivers/fv3/help.out | 4 +-
.../cli/drivers/fv3/show-schema.cmd | 2 +
.../cli/drivers/fv3/show-schema.out | 21 ++
.../cli/drivers/global_equiv_resol.rst | 8 +
.../cli/drivers/global_equiv_resol/help.out | 4 +-
.../global_equiv_resol/show-schema.cmd | 2 +
.../global_equiv_resol/show-schema.out | 21 ++
docs/sections/user_guide/cli/drivers/ioda.rst | 8 +
.../user_guide/cli/drivers/ioda/help.out | 4 +-
.../cli/drivers/ioda/show-schema.cmd | 2 +
.../cli/drivers/ioda/show-schema.out | 21 ++
docs/sections/user_guide/cli/drivers/jedi.rst | 8 +
.../user_guide/cli/drivers/jedi/help.out | 4 +-
.../cli/drivers/jedi/show-schema.cmd | 2 +
.../cli/drivers/jedi/show-schema.out | 21 ++
.../user_guide/cli/drivers/make_hgrid.rst | 8 +
.../cli/drivers/make_hgrid/help.out | 4 +-
.../cli/drivers/make_hgrid/show-schema.cmd | 2 +
.../cli/drivers/make_hgrid/show-schema.out | 21 ++
.../cli/drivers/make_solo_mosaic.rst | 8 +
.../cli/drivers/make_solo_mosaic/help.out | 4 +-
.../drivers/make_solo_mosaic/show-schema.cmd | 2 +
.../drivers/make_solo_mosaic/show-schema.out | 21 ++
docs/sections/user_guide/cli/drivers/mpas.rst | 8 +
.../user_guide/cli/drivers/mpas/help.out | 4 +-
.../cli/drivers/mpas/show-schema.cmd | 2 +
.../cli/drivers/mpas/show-schema.out | 21 ++
.../user_guide/cli/drivers/mpas_init.rst | 8 +
.../user_guide/cli/drivers/mpas_init/help.out | 4 +-
.../cli/drivers/mpas_init/show-schema.cmd | 2 +
.../cli/drivers/mpas_init/show-schema.out | 21 ++
.../user_guide/cli/drivers/orog_gsl.rst | 8 +
.../user_guide/cli/drivers/orog_gsl/help.out | 4 +-
.../cli/drivers/orog_gsl/show-schema.cmd | 2 +
.../cli/drivers/orog_gsl/show-schema.out | 21 ++
.../user_guide/cli/drivers/sfc_climo_gen.rst | 8 +
.../cli/drivers/sfc_climo_gen/help.out | 4 +-
.../cli/drivers/sfc_climo_gen/show-schema.cmd | 2 +
.../cli/drivers/sfc_climo_gen/show-schema.out | 21 ++
.../sections/user_guide/cli/drivers/shave.rst | 8 +
.../user_guide/cli/drivers/shave/help.out | 4 +-
.../cli/drivers/shave/show-schema.cmd | 2 +
.../cli/drivers/shave/show-schema.out | 21 ++
.../user_guide/cli/drivers/ungrib.rst | 8 +
.../user_guide/cli/drivers/ungrib/help.out | 4 +-
.../cli/drivers/ungrib/show-schema.cmd | 2 +
.../cli/drivers/ungrib/show-schema.out | 21 ++
docs/sections/user_guide/cli/drivers/upp.rst | 8 +
.../user_guide/cli/drivers/upp/help.out | 4 +-
.../cli/drivers/upp/show-schema.cmd | 2 +
.../cli/drivers/upp/show-schema.out | 21 ++
.../user_guide/cli/tools/execute/rand.py | 4 +-
docs/sections/user_guide/cli/tools/file.rst | 97 ---------
.../file/copy-exec-no-target-dir-err.cmd | 1 -
.../file/copy-exec-no-target-dir-err.out | 1 -
.../cli/tools/file/copy-exec-timedep.cmd | 4 -
.../user_guide/cli/tools/file/copy-exec.cmd | 4 -
.../user_guide/cli/tools/file/copy-help.cmd | 1 -
.../user_guide/cli/tools/file/help.cmd | 1 -
.../file/link-exec-no-target-dir-err.cmd | 1 -
.../file/link-exec-no-target-dir-err.out | 1 -
.../cli/tools/file/link-exec-timedep.cmd | 4 -
.../user_guide/cli/tools/file/link-exec.cmd | 4 -
.../user_guide/cli/tools/file/link-help.cmd | 1 -
docs/sections/user_guide/cli/tools/fs.rst | 135 ++++++++++++
.../cli/tools/{file => fs}/.gitignore | 0
.../cli/tools/{file => fs}/Makefile | 0
.../{file => fs}/copy-config-timedep.yaml | 0
.../cli/tools/{file => fs}/copy-config.yaml | 0
.../tools/fs/copy-exec-no-target-dir-err.cmd | 1 +
.../tools/fs/copy-exec-no-target-dir-err.out | 3 +
.../cli/tools/fs/copy-exec-timedep.cmd | 4 +
.../tools/{file => fs}/copy-exec-timedep.out | 0
.../user_guide/cli/tools/fs/copy-exec.cmd | 4 +
.../cli/tools/{file => fs}/copy-exec.out | 0
.../user_guide/cli/tools/fs/copy-help.cmd | 1 +
.../cli/tools/{file => fs}/copy-help.out | 8 +-
.../sections/user_guide/cli/tools/fs/help.cmd | 1 +
.../cli/tools/{file => fs}/help.out | 6 +-
.../{file => fs}/link-config-timedep.yaml | 0
.../cli/tools/{file => fs}/link-config.yaml | 0
.../tools/fs/link-exec-no-target-dir-err.cmd | 1 +
.../tools/fs/link-exec-no-target-dir-err.out | 3 +
.../cli/tools/fs/link-exec-timedep.cmd | 4 +
.../tools/{file => fs}/link-exec-timedep.out | 0
.../user_guide/cli/tools/fs/link-exec.cmd | 4 +
.../cli/tools/{file => fs}/link-exec.out | 0
.../user_guide/cli/tools/fs/link-help.cmd | 1 +
.../cli/tools/{file => fs}/link-help.out | 8 +-
.../cli/tools/fs/makedirs-config-timedep.yaml | 8 +
.../cli/tools/fs/makedirs-config.yaml | 4 +
.../fs/makedirs-exec-no-target-dir-err.cmd | 1 +
.../fs/makedirs-exec-no-target-dir-err.out | 3 +
.../cli/tools/fs/makedirs-exec-timedep.cmd | 4 +
.../cli/tools/fs/makedirs-exec-timedep.out | 29 +++
.../user_guide/cli/tools/fs/makedirs-exec.cmd | 4 +
.../user_guide/cli/tools/fs/makedirs-exec.out | 21 ++
.../user_guide/cli/tools/fs/makedirs-help.cmd | 1 +
.../user_guide/cli/tools/fs/makedirs-help.out | 28 +++
.../{file => fs}/src/20240529/12/006/baz | 0
.../user_guide/cli/tools/{file => fs}/src/bar | 0
.../user_guide/cli/tools/{file => fs}/src/foo | 0
docs/sections/user_guide/cli/tools/index.rst | 2 +-
docs/sections/user_guide/yaml/files.rst | 2 +-
docs/sections/user_guide/yaml/index.rst | 5 +-
docs/sections/user_guide/yaml/makedirs.rst | 22 ++
recipe/meta.json | 2 +-
src/uwtools/api/cdeps.py | 24 ++-
src/uwtools/api/chgres_cube.py | 24 ++-
src/uwtools/api/config.py | 55 +++--
src/uwtools/api/driver.py | 153 --------------
src/uwtools/api/esg_grid.py | 24 ++-
src/uwtools/api/execute.py | 157 ++++++++++++++
src/uwtools/api/filter_topo.py | 24 ++-
src/uwtools/api/{file.py => fs.py} | 47 ++++-
src/uwtools/api/fv3.py | 24 ++-
src/uwtools/api/global_equiv_resol.py | 24 ++-
src/uwtools/api/ioda.py | 24 ++-
src/uwtools/api/jedi.py | 24 ++-
src/uwtools/api/make_hgrid.py | 24 ++-
src/uwtools/api/make_solo_mosaic.py | 24 ++-
src/uwtools/api/mpas.py | 24 ++-
src/uwtools/api/mpas_init.py | 24 ++-
src/uwtools/api/orog_gsl.py | 24 ++-
src/uwtools/api/schism.py | 24 ++-
src/uwtools/api/sfc_climo_gen.py | 24 ++-
src/uwtools/api/shave.py | 24 ++-
src/uwtools/api/ungrib.py | 24 ++-
src/uwtools/api/upp.py | 24 ++-
src/uwtools/api/ww3.py | 24 ++-
src/uwtools/cli.py | 167 +++++++++------
src/uwtools/config/atparse_to_jinja2.py | 2 +-
src/uwtools/config/formats/base.py | 172 ++++++++--------
src/uwtools/config/formats/fieldtable.py | 57 +++---
src/uwtools/config/formats/ini.py | 46 ++---
src/uwtools/config/formats/nml.py | 45 ++--
src/uwtools/config/formats/sh.py | 46 ++---
src/uwtools/config/formats/yaml.py | 125 ++++++------
src/uwtools/config/jinja2.py | 6 +-
src/uwtools/config/support.py | 4 +-
src/uwtools/config/tools.py | 28 ++-
src/uwtools/config/validator.py | 47 ++++-
src/uwtools/drivers/cdeps.py | 18 +-
src/uwtools/drivers/chgres_cube.py | 6 +-
src/uwtools/drivers/driver.py | 74 ++++---
src/uwtools/drivers/esg_grid.py | 6 +-
src/uwtools/drivers/filter_topo.py | 6 +-
src/uwtools/drivers/fv3.py | 6 +-
src/uwtools/drivers/global_equiv_resol.py | 8 +-
src/uwtools/drivers/ioda.py | 10 +-
src/uwtools/drivers/jedi.py | 10 +-
src/uwtools/drivers/jedi_base.py | 2 +-
src/uwtools/drivers/make_hgrid.py | 8 +-
src/uwtools/drivers/make_solo_mosaic.py | 12 +-
src/uwtools/drivers/mpas.py | 6 +-
src/uwtools/drivers/mpas_init.py | 6 +-
src/uwtools/drivers/orog_gsl.py | 8 +-
src/uwtools/drivers/schism.py | 6 +-
src/uwtools/drivers/sfc_climo_gen.py | 6 +-
src/uwtools/drivers/shave.py | 8 +-
src/uwtools/drivers/support.py | 6 +-
src/uwtools/drivers/ungrib.py | 10 +-
src/uwtools/drivers/upp.py | 10 +-
src/uwtools/drivers/ww3.py | 6 +-
src/uwtools/{file.py => fs.py} | 127 +++++++++---
src/uwtools/resources/info.json | 2 +-
.../resources/jsonschema/makedirs.jsonschema | 16 ++
src/uwtools/rocoto.py | 14 +-
src/uwtools/scheduler.py | 62 +++---
src/uwtools/strings.py | 7 +-
src/uwtools/tests/api/test_config.py | 39 +++-
src/uwtools/tests/api/test_driver.py | 193 +-----------------
src/uwtools/tests/api/test_drivers.py | 11 +-
src/uwtools/tests/api/test_execute.py | 190 +++++++++++++++++
.../tests/api/{test_file.py => test_fs.py} | 17 +-
src/uwtools/tests/config/formats/test_base.py | 82 ++++----
.../tests/config/formats/test_fieldtable.py | 15 +-
src/uwtools/tests/config/formats/test_ini.py | 24 +--
src/uwtools/tests/config/formats/test_nml.py | 68 +++---
src/uwtools/tests/config/formats/test_sh.py | 24 +--
src/uwtools/tests/config/formats/test_yaml.py | 56 ++---
src/uwtools/tests/config/test_tools.py | 2 +-
src/uwtools/tests/config/test_validator.py | 29 ++-
src/uwtools/tests/drivers/test_cdeps.py | 2 +-
src/uwtools/tests/drivers/test_chgres_cube.py | 2 +-
src/uwtools/tests/drivers/test_driver.py | 15 +-
src/uwtools/tests/drivers/test_esg_grid.py | 2 +-
src/uwtools/tests/drivers/test_filter_topo.py | 2 +-
src/uwtools/tests/drivers/test_fv3.py | 2 +-
.../tests/drivers/test_global_equiv_resol.py | 2 +-
src/uwtools/tests/drivers/test_ioda.py | 2 +-
src/uwtools/tests/drivers/test_jedi.py | 2 +-
src/uwtools/tests/drivers/test_make_hgrid.py | 2 +-
.../tests/drivers/test_make_solo_mosaic.py | 2 +-
src/uwtools/tests/drivers/test_mpas.py | 2 +-
src/uwtools/tests/drivers/test_mpas_init.py | 2 +-
src/uwtools/tests/drivers/test_orog_gsl.py | 2 +-
src/uwtools/tests/drivers/test_schism.py | 2 +-
.../tests/drivers/test_sfc_climo_gen.py | 2 +-
src/uwtools/tests/drivers/test_shave.py | 2 +-
src/uwtools/tests/drivers/test_support.py | 4 +-
src/uwtools/tests/drivers/test_ungrib.py | 2 +-
src/uwtools/tests/drivers/test_upp.py | 2 +-
src/uwtools/tests/drivers/test_ww3.py | 2 +-
src/uwtools/tests/fixtures/testdriver.py | 4 +-
src/uwtools/tests/support.py | 8 +-
src/uwtools/tests/test_cli.py | 78 ++++---
.../tests/{test_file.py => test_fs.py} | 63 ++++--
src/uwtools/tests/test_rocoto.py | 9 +
src/uwtools/tests/test_scheduler.py | 6 +-
src/uwtools/tests/test_schemas.py | 17 +-
src/uwtools/tests/utils/test_processing.py | 10 +-
src/uwtools/tests/utils/test_tasks.py | 7 +
src/uwtools/utils/api.py | 2 +-
src/uwtools/utils/file.py | 14 +-
src/uwtools/utils/memory.py | 6 +-
src/uwtools/utils/processing.py | 12 +-
src/uwtools/utils/tasks.py | 13 ++
245 files changed, 2820 insertions(+), 1409 deletions(-)
create mode 100644 docs/sections/user_guide/api/execute.rst
delete mode 100644 docs/sections/user_guide/api/file.rst
create mode 100644 docs/sections/user_guide/api/fs.rst
create mode 100644 docs/sections/user_guide/cli/drivers/.gitignore
create mode 100644 docs/sections/user_guide/cli/drivers/cdeps/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/cdeps/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/esg_grid/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/esg_grid/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/filter_topo/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/fv3/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/fv3/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/ioda/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/ioda/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/jedi/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/jedi/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/mpas/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/mpas/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/mpas_init/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/mpas_init/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/shave/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/shave/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/ungrib/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/ungrib/show-schema.out
create mode 100644 docs/sections/user_guide/cli/drivers/upp/show-schema.cmd
create mode 100644 docs/sections/user_guide/cli/drivers/upp/show-schema.out
delete mode 100644 docs/sections/user_guide/cli/tools/file.rst
delete mode 100644 docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.out
delete mode 100644 docs/sections/user_guide/cli/tools/file/copy-exec-timedep.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/copy-exec.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/copy-help.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/help.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.out
delete mode 100644 docs/sections/user_guide/cli/tools/file/link-exec-timedep.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/link-exec.cmd
delete mode 100644 docs/sections/user_guide/cli/tools/file/link-help.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs.rst
rename docs/sections/user_guide/cli/tools/{file => fs}/.gitignore (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/Makefile (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/copy-config-timedep.yaml (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/copy-config.yaml (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.out
create mode 100644 docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/copy-exec-timedep.out (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/copy-exec.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/copy-exec.out (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/copy-help.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/copy-help.out (73%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/help.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/help.out (60%)
rename docs/sections/user_guide/cli/tools/{file => fs}/link-config-timedep.yaml (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/link-config.yaml (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.out
create mode 100644 docs/sections/user_guide/cli/tools/fs/link-exec-timedep.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/link-exec-timedep.out (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/link-exec.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/link-exec.out (100%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/link-help.cmd
rename docs/sections/user_guide/cli/tools/{file => fs}/link-help.out (73%)
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-config-timedep.yaml
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-config.yaml
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.out
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.out
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-exec.out
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-help.cmd
create mode 100644 docs/sections/user_guide/cli/tools/fs/makedirs-help.out
rename docs/sections/user_guide/cli/tools/{file => fs}/src/20240529/12/006/baz (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/src/bar (100%)
rename docs/sections/user_guide/cli/tools/{file => fs}/src/foo (100%)
create mode 100644 docs/sections/user_guide/yaml/makedirs.rst
create mode 100644 src/uwtools/api/execute.py
rename src/uwtools/api/{file.py => fs.py} (61%)
rename src/uwtools/{file.py => fs.py} (52%)
create mode 100644 src/uwtools/resources/jsonschema/makedirs.jsonschema
create mode 100644 src/uwtools/tests/api/test_execute.py
rename src/uwtools/tests/api/{test_file.py => test_fs.py} (77%)
rename src/uwtools/tests/{test_file.py => test_fs.py} (69%)
diff --git a/docs/Makefile b/docs/Makefile
index 5f68e1b41..72693a881 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -2,7 +2,7 @@ BUILDDIR = build
LINKCHECKDIR = $(BUILDDIR)/linkcheck
SOURCEDIR = .
SPHINXBUILD = sphinx-build
-SPHINXOPTS = -a -n -W
+SPHINXOPTS = -a -n -W --keep-going
.PHONY: help clean docs examples linkcheck
diff --git a/docs/conf.py b/docs/conf.py
index b19d1b6f5..0087720b5 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -21,7 +21,7 @@
html_theme = "sphinx_rtd_theme"
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
linkcheck_ignore = [r"https://github.com/.*#.*"]
-nitpick_ignore_regex = [("py:class", r"^uwtools\..*")]
+nitpick_ignore_regex = [("py:class", r"^uwtools\..*"), ("py:class", "f90nml.Namelist")]
numfig = True
numfig_format = {"figure": "Figure %s"}
project = "Unified Workflow Tools"
diff --git a/docs/index.rst b/docs/index.rst
index b89e1cb05..c99bac998 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -71,22 +71,23 @@ This tool helps transform legacy configuration files templated with the atparse
| :any:`CLI documentation with examples`
-File Provisioning
-^^^^^^^^^^^^^^^^^
+File/Directory Provisioning
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
-This tool helps users define the source and destination of files to be copied or linked, in the same UW YAML language used by UW drivers.
+| **CLI**: ``uw fs -h``
+| **API**: ``import uwtools.api.fs``
-| :any:`CLI documentation with examples`
+This tool helps users define the source and destination of files to be copied or linked, or directories to be created, in the same UW YAML language used by UW drivers.
-There is a video demonstration of the use of the ``uw file`` tool available via YouTube.
+| :any:`CLI documentation with examples`
+
+There is a video demonstration of the use of the ``uw fs`` tool (formerly ``uw file``) available via YouTube.
.. raw:: html
-|
-
Rocoto Configurability
^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/sections/user_guide/api/config.rst b/docs/sections/user_guide/api/config.rst
index ddef1bb57..5f689e1e7 100644
--- a/docs/sections/user_guide/api/config.rst
+++ b/docs/sections/user_guide/api/config.rst
@@ -2,4 +2,5 @@
======================
.. automodule:: uwtools.api.config
+ :inherited-members: UserDict
:members:
diff --git a/docs/sections/user_guide/api/execute.rst b/docs/sections/user_guide/api/execute.rst
new file mode 100644
index 000000000..ddff82164
--- /dev/null
+++ b/docs/sections/user_guide/api/execute.rst
@@ -0,0 +1,6 @@
+``uwtools.api.execute``
+=======================
+
+.. automodule:: uwtools.api.execute
+ :inherited-members:
+ :members:
diff --git a/docs/sections/user_guide/api/file.rst b/docs/sections/user_guide/api/file.rst
deleted file mode 100644
index d705c81bc..000000000
--- a/docs/sections/user_guide/api/file.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``uwtools.api.file``
-====================
-
-.. automodule:: uwtools.api.file
- :members:
diff --git a/docs/sections/user_guide/api/fs.rst b/docs/sections/user_guide/api/fs.rst
new file mode 100644
index 000000000..0ac50fc87
--- /dev/null
+++ b/docs/sections/user_guide/api/fs.rst
@@ -0,0 +1,5 @@
+``uwtools.api.fs``
+==================
+
+.. automodule:: uwtools.api.fs
+ :members:
diff --git a/docs/sections/user_guide/api/index.rst b/docs/sections/user_guide/api/index.rst
index 8e4336103..cd29d7614 100644
--- a/docs/sections/user_guide/api/index.rst
+++ b/docs/sections/user_guide/api/index.rst
@@ -7,8 +7,9 @@ API
config
driver
esg_grid
- file
+ execute
filter_topo
+ fs
fv3
global_equiv_resol
ioda
diff --git a/docs/sections/user_guide/cli/drivers/.gitignore b/docs/sections/user_guide/cli/drivers/.gitignore
new file mode 100644
index 000000000..1c12cf9bd
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/.gitignore
@@ -0,0 +1 @@
+schema
diff --git a/docs/sections/user_guide/cli/drivers/cdeps.rst b/docs/sections/user_guide/cli/drivers/cdeps.rst
index c2e98694d..1044ad6c6 100644
--- a/docs/sections/user_guide/cli/drivers/cdeps.rst
+++ b/docs/sections/user_guide/cli/drivers/cdeps.rst
@@ -37,7 +37,6 @@ Its contents are described in depth in section :ref:`cdeps_yaml`. Each of the va
The driver creates a ``datm_in`` Fortran namelist file and a ``datm.streams`` stream-configuration file in the directory specified by ``rundir:`` in the config.
-
* Specifying the ``--dry-run`` flag results in the driver logging messages about actions it would have taken, without actually taking any.
.. code-block:: text
@@ -45,3 +44,11 @@ Its contents are described in depth in section :ref:`cdeps_yaml`. Each of the va
$ uw cdeps run --config-file config.yaml --cycle 2023-12-15T18 --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: cdeps/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: cdeps/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/cdeps/help.out b/docs/sections/user_guide/cli/drivers/cdeps/help.out
index 9bc97fa19..8fdcbbf8e 100644
--- a/docs/sections/user_guide/cli/drivers/cdeps/help.out
+++ b/docs/sections/user_guide/cli/drivers/cdeps/help.out
@@ -1,4 +1,4 @@
-usage: uw cdeps [-h] [--version] TASK ...
+usage: uw cdeps [-h] [--version] [--show-schema] TASK ...
Execute cdeps tasks
@@ -7,20 +7,22 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
atm
- Create data atmosphere configuration with all required content
+ The data atmosphere configuration with all required content
atm_nml
- Create data atmosphere Fortran namelist file (datm_in)
+ The data atmosphere Fortran namelist file (datm_in)
atm_stream
- Create data atmosphere stream config file (datm.streams)
+ The data atmosphere stream config file (datm.streams)
ocn
- Create data ocean configuration with all required content
+ The data ocean configuration with all required content
ocn_nml
- Create data ocean Fortran namelist file (docn_in)
+ The data ocean Fortran namelist file (docn_in)
ocn_stream
- Create data ocean stream config file (docn.streams)
+ The data ocean stream config file (docn.streams)
validate
Validate the UW driver config
diff --git a/docs/sections/user_guide/cli/drivers/cdeps/run-help.out b/docs/sections/user_guide/cli/drivers/cdeps/run-help.out
index 9eb45e799..7ddba5ecc 100644
--- a/docs/sections/user_guide/cli/drivers/cdeps/run-help.out
+++ b/docs/sections/user_guide/cli/drivers/cdeps/run-help.out
@@ -2,7 +2,7 @@ usage: uw cdeps atm --cycle CYCLE [-h] [--version] [--config-file PATH]
[--dry-run] [--graph-file PATH] [--key-path KEY[.KEY...]]
[--quiet] [--verbose]
-Create data atmosphere configuration with all required content
+The data atmosphere configuration with all required content
Required arguments:
--cycle CYCLE
diff --git a/docs/sections/user_guide/cli/drivers/cdeps/show-schema.cmd b/docs/sections/user_guide/cli/drivers/cdeps/show-schema.cmd
new file mode 100644
index 000000000..5be54d6cd
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/cdeps/show-schema.cmd
@@ -0,0 +1,2 @@
+uw cdeps --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/cdeps/show-schema.out b/docs/sections/user_guide/cli/drivers/cdeps/show-schema.out
new file mode 100644
index 000000000..b5e4a7c03
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/cdeps/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "$defs": {
+ "streams": {
+ "additionalProperties": false,
+ "minProperties": 1,
+ "patternProperties": {
+ "^stream0[1-9]$": {
+ "additionalProperties": false,
+ "properties": {
+ "dtlimit": {
+...
+ "required": [
+ "rundir"
+ ]
+ }
+ },
+ "required": [
+ "cdeps"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/chgres_cube.rst b/docs/sections/user_guide/cli/drivers/chgres_cube.rst
index 61f55661f..6d954a1c6 100644
--- a/docs/sections/user_guide/cli/drivers/chgres_cube.rst
+++ b/docs/sections/user_guide/cli/drivers/chgres_cube.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`chgres_cube_yaml`. Each of
.. code-block:: text
$ uw chgres_cube provisioned_rundir --config-file config.yaml --cycle 2023-12-15T18 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: chgres_cube/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: chgres_cube/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/chgres_cube/help.out b/docs/sections/user_guide/cli/drivers/chgres_cube/help.out
index 11879ab3c..7d632bb8b 100644
--- a/docs/sections/user_guide/cli/drivers/chgres_cube/help.out
+++ b/docs/sections/user_guide/cli/drivers/chgres_cube/help.out
@@ -1,4 +1,4 @@
-usage: uw chgres_cube [-h] [--version] TASK ...
+usage: uw chgres_cube [-h] [--version] [--show-schema] TASK ...
Execute chgres_cube tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.cmd b/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.cmd
new file mode 100644
index 000000000..f539df7cc
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.cmd
@@ -0,0 +1,2 @@
+uw chgres_cube --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.out b/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.out
new file mode 100644
index 000000000..97d8138dd
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/chgres_cube/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "chgres_cube": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "chgres_cube"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/esg_grid.rst b/docs/sections/user_guide/cli/drivers/esg_grid.rst
index 7bacfb75a..e04e8af80 100644
--- a/docs/sections/user_guide/cli/drivers/esg_grid.rst
+++ b/docs/sections/user_guide/cli/drivers/esg_grid.rst
@@ -58,3 +58,11 @@ The driver creates a ``runscript.esg_grid`` file in the directory specified by `
.. code-block:: text
$ uw esg_grid provisioned_rundir --config-file config.yaml --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: esg_grid/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: esg_grid/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/esg_grid/help.out b/docs/sections/user_guide/cli/drivers/esg_grid/help.out
index 01a1825fe..0069553e6 100644
--- a/docs/sections/user_guide/cli/drivers/esg_grid/help.out
+++ b/docs/sections/user_guide/cli/drivers/esg_grid/help.out
@@ -1,4 +1,4 @@
-usage: uw esg_grid [-h] [--version] TASK ...
+usage: uw esg_grid [-h] [--version] [--show-schema] TASK ...
Execute esg_grid tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.cmd b/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.cmd
new file mode 100644
index 000000000..a98ee17dc
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.cmd
@@ -0,0 +1,2 @@
+uw esg_grid --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.out b/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.out
new file mode 100644
index 000000000..8ac2cdd16
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/esg_grid/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "$defs": {
+ "base_file": {
+ "type": "string"
+ },
+ "namelist_content": {
+ "additionalproperties": false,
+ "properties": {
+ "regional_grid_nml": {
+ "additionalProperties": false,
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "esg_grid"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/filter_topo.rst b/docs/sections/user_guide/cli/drivers/filter_topo.rst
index 1787c9125..a7a77ff5d 100644
--- a/docs/sections/user_guide/cli/drivers/filter_topo.rst
+++ b/docs/sections/user_guide/cli/drivers/filter_topo.rst
@@ -52,3 +52,11 @@ Its contents are described in section :ref:`filter_topo_yaml`.
$ uw filter_topo run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: filter_topo/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: filter_topo/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/filter_topo/help.out b/docs/sections/user_guide/cli/drivers/filter_topo/help.out
index a1fe57f9f..1c426c0f4 100644
--- a/docs/sections/user_guide/cli/drivers/filter_topo/help.out
+++ b/docs/sections/user_guide/cli/drivers/filter_topo/help.out
@@ -1,4 +1,4 @@
-usage: uw filter_topo [-h] [--version] TASK ...
+usage: uw filter_topo [-h] [--version] [--show-schema] TASK ...
Execute filter_topo tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.cmd b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.cmd
new file mode 100644
index 000000000..43883c9fa
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.cmd
@@ -0,0 +1,2 @@
+uw filter_topo --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out
new file mode 100644
index 000000000..d1ff44ffc
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/filter_topo/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "filter_topo": {
+ "additionalProperties": false,
+ "properties": {
+ "config": {
+ "additionalProperties": false,
+ "properties": {
+ "input_grid_file": {
+ "type": "string"
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "filter_topo"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/fv3.rst b/docs/sections/user_guide/cli/drivers/fv3.rst
index 2e1e2d858..c601d7464 100644
--- a/docs/sections/user_guide/cli/drivers/fv3.rst
+++ b/docs/sections/user_guide/cli/drivers/fv3.rst
@@ -53,3 +53,11 @@ The examples use a configuration file named ``config.yaml``. Its contents are de
.. code-block:: text
$ uw fv3 provisioned_rundir --config-file config.yaml --cycle 2024-02-11T12 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: fv3/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: fv3/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/fv3/help.out b/docs/sections/user_guide/cli/drivers/fv3/help.out
index e2fd2adf2..09420ef44 100644
--- a/docs/sections/user_guide/cli/drivers/fv3/help.out
+++ b/docs/sections/user_guide/cli/drivers/fv3/help.out
@@ -1,4 +1,4 @@
-usage: uw fv3 [-h] [--version] TASK ...
+usage: uw fv3 [-h] [--version] [--show-schema] TASK ...
Execute fv3 tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/fv3/show-schema.cmd b/docs/sections/user_guide/cli/drivers/fv3/show-schema.cmd
new file mode 100644
index 000000000..25d915cea
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/fv3/show-schema.cmd
@@ -0,0 +1,2 @@
+uw fv3 --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/fv3/show-schema.out b/docs/sections/user_guide/cli/drivers/fv3/show-schema.out
new file mode 100644
index 000000000..6957302c1
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/fv3/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "fv3": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "domain": {
+ "const": "regional"
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "fv3"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/global_equiv_resol.rst b/docs/sections/user_guide/cli/drivers/global_equiv_resol.rst
index f6a189727..f02e99760 100644
--- a/docs/sections/user_guide/cli/drivers/global_equiv_resol.rst
+++ b/docs/sections/user_guide/cli/drivers/global_equiv_resol.rst
@@ -52,3 +52,11 @@ Its contents are described in section :ref:`global_equiv_resol_yaml`.
$ uw global_equiv_resol run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: global_equiv_resol/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: global_equiv_resol/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/global_equiv_resol/help.out b/docs/sections/user_guide/cli/drivers/global_equiv_resol/help.out
index 227ad0499..eb3d7b505 100644
--- a/docs/sections/user_guide/cli/drivers/global_equiv_resol/help.out
+++ b/docs/sections/user_guide/cli/drivers/global_equiv_resol/help.out
@@ -1,4 +1,4 @@
-usage: uw global_equiv_resol [-h] [--version] TASK ...
+usage: uw global_equiv_resol [-h] [--version] [--show-schema] TASK ...
Execute global_equiv_resol tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.cmd b/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.cmd
new file mode 100644
index 000000000..6d43fbfb2
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.cmd
@@ -0,0 +1,2 @@
+uw global_equiv_resol --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.out b/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.out
new file mode 100644
index 000000000..bfdd2e0e0
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/global_equiv_resol/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "global_equiv_resol": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "global_equiv_resol"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/ioda.rst b/docs/sections/user_guide/cli/drivers/ioda.rst
index 132337a44..9ea6e745c 100644
--- a/docs/sections/user_guide/cli/drivers/ioda.rst
+++ b/docs/sections/user_guide/cli/drivers/ioda.rst
@@ -52,3 +52,11 @@ The driver creates a ``runscript.ioda`` file in the directory specified by ``run
$ uw ioda run --config-file config.yaml --cycle 2024-05-22T12 --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: ioda/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: ioda/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/ioda/help.out b/docs/sections/user_guide/cli/drivers/ioda/help.out
index f7486be93..692138971 100644
--- a/docs/sections/user_guide/cli/drivers/ioda/help.out
+++ b/docs/sections/user_guide/cli/drivers/ioda/help.out
@@ -1,4 +1,4 @@
-usage: uw ioda [-h] [--version] TASK ...
+usage: uw ioda [-h] [--version] [--show-schema] TASK ...
Execute ioda tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/ioda/show-schema.cmd b/docs/sections/user_guide/cli/drivers/ioda/show-schema.cmd
new file mode 100644
index 000000000..1b16cbe71
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/ioda/show-schema.cmd
@@ -0,0 +1,2 @@
+uw ioda --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/ioda/show-schema.out b/docs/sections/user_guide/cli/drivers/ioda/show-schema.out
new file mode 100644
index 000000000..f90bcf6a7
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/ioda/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "ioda": {
+ "additionalProperties": false,
+ "properties": {
+ "configuration_file": {
+ "additionalProperties": false,
+ "anyOf": [
+ {
+ "required": [
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "ioda"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/jedi.rst b/docs/sections/user_guide/cli/drivers/jedi.rst
index 1aa90992f..464300b94 100644
--- a/docs/sections/user_guide/cli/drivers/jedi.rst
+++ b/docs/sections/user_guide/cli/drivers/jedi.rst
@@ -52,3 +52,11 @@ The driver creates a ``runscript.jedi`` file in the directory specified by ``run
$ uw jedi run --config-file config.yaml --cycle 2024-05-22T12 --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: jedi/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: jedi/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/jedi/help.out b/docs/sections/user_guide/cli/drivers/jedi/help.out
index f35898de0..fe3d01ae0 100644
--- a/docs/sections/user_guide/cli/drivers/jedi/help.out
+++ b/docs/sections/user_guide/cli/drivers/jedi/help.out
@@ -1,4 +1,4 @@
-usage: uw jedi [-h] [--version] TASK ...
+usage: uw jedi [-h] [--version] [--show-schema] TASK ...
Execute jedi tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/jedi/show-schema.cmd b/docs/sections/user_guide/cli/drivers/jedi/show-schema.cmd
new file mode 100644
index 000000000..0567b841e
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/jedi/show-schema.cmd
@@ -0,0 +1,2 @@
+uw jedi --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/jedi/show-schema.out b/docs/sections/user_guide/cli/drivers/jedi/show-schema.out
new file mode 100644
index 000000000..2dba61813
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/jedi/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "jedi": {
+ "additionalProperties": false,
+ "properties": {
+ "configuration_file": {
+ "additionalProperties": false,
+ "anyOf": [
+ {
+ "required": [
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "jedi"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/make_hgrid.rst b/docs/sections/user_guide/cli/drivers/make_hgrid.rst
index 8d38fd152..dc6d79dd3 100644
--- a/docs/sections/user_guide/cli/drivers/make_hgrid.rst
+++ b/docs/sections/user_guide/cli/drivers/make_hgrid.rst
@@ -60,3 +60,11 @@ Its contents are described in section :ref:`make_hgrid_yaml`.
$ uw make_hgrid run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: make_hgrid/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: make_hgrid/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/make_hgrid/help.out b/docs/sections/user_guide/cli/drivers/make_hgrid/help.out
index c61016726..7acf294ca 100644
--- a/docs/sections/user_guide/cli/drivers/make_hgrid/help.out
+++ b/docs/sections/user_guide/cli/drivers/make_hgrid/help.out
@@ -1,4 +1,4 @@
-usage: uw make_hgrid [-h] [--version] TASK ...
+usage: uw make_hgrid [-h] [--version] [--show-schema] TASK ...
Execute make_hgrid tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.cmd b/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.cmd
new file mode 100644
index 000000000..e41d2138c
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.cmd
@@ -0,0 +1,2 @@
+uw make_hgrid --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.out b/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.out
new file mode 100644
index 000000000..181977dd5
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/make_hgrid/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "make_hgrid": {
+ "additionalProperties": false,
+ "properties": {
+ "config": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+...
+ "execution",
+ "rundir"
+ ]
+ }
+ },
+ "required": [
+ "make_hgrid"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/make_solo_mosaic.rst b/docs/sections/user_guide/cli/drivers/make_solo_mosaic.rst
index d3c95e77b..7d110acad 100644
--- a/docs/sections/user_guide/cli/drivers/make_solo_mosaic.rst
+++ b/docs/sections/user_guide/cli/drivers/make_solo_mosaic.rst
@@ -61,3 +61,11 @@ Its contents are described in section :ref:`make_solo_mosaic_yaml`.
$ uw make_solo_mosaic run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: make_solo_mosaic/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: make_solo_mosaic/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/make_solo_mosaic/help.out b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/help.out
index 1af7ff17e..0b899c013 100644
--- a/docs/sections/user_guide/cli/drivers/make_solo_mosaic/help.out
+++ b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/help.out
@@ -1,4 +1,4 @@
-usage: uw make_solo_mosaic [-h] [--version] TASK ...
+usage: uw make_solo_mosaic [-h] [--version] [--show-schema] TASK ...
Execute make_solo_mosaic tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.cmd b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.cmd
new file mode 100644
index 000000000..8100c4a37
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.cmd
@@ -0,0 +1,2 @@
+uw make_solo_mosaic --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.out b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.out
new file mode 100644
index 000000000..61d30fd75
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/make_solo_mosaic/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "make_solo_mosaic": {
+ "additionalProperties": false,
+ "properties": {
+ "config": {
+ "additionalProperties": false,
+ "properties": {
+ "dir": {
+ "type": "string"
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "make_solo_mosaic"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/mpas.rst b/docs/sections/user_guide/cli/drivers/mpas.rst
index 671d019a0..090d50a92 100644
--- a/docs/sections/user_guide/cli/drivers/mpas.rst
+++ b/docs/sections/user_guide/cli/drivers/mpas.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`mpas_yaml`.
.. code-block:: text
$ uw mpas provisioned_rundir --config-file config.yaml --cycle 2025-02-12T12 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: mpas/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: mpas/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/mpas/help.out b/docs/sections/user_guide/cli/drivers/mpas/help.out
index 000018cdf..cb7106e5f 100644
--- a/docs/sections/user_guide/cli/drivers/mpas/help.out
+++ b/docs/sections/user_guide/cli/drivers/mpas/help.out
@@ -1,4 +1,4 @@
-usage: uw mpas [-h] [--version] TASK ...
+usage: uw mpas [-h] [--version] [--show-schema] TASK ...
Execute mpas tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/mpas/show-schema.cmd b/docs/sections/user_guide/cli/drivers/mpas/show-schema.cmd
new file mode 100644
index 000000000..d1293fe18
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/mpas/show-schema.cmd
@@ -0,0 +1,2 @@
+uw mpas --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/mpas/show-schema.out b/docs/sections/user_guide/cli/drivers/mpas/show-schema.out
new file mode 100644
index 000000000..561d89def
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/mpas/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "mpas": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "streams"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "mpas"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/mpas_init.rst b/docs/sections/user_guide/cli/drivers/mpas_init.rst
index 30be881e4..b8286a929 100644
--- a/docs/sections/user_guide/cli/drivers/mpas_init.rst
+++ b/docs/sections/user_guide/cli/drivers/mpas_init.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`mpas_init_yaml`.
.. code-block:: text
$ uw mpas_init provisioned_rundir --config-file config.yaml --cycle 2023-12-18T00 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: mpas_init/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: mpas_init/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/mpas_init/help.out b/docs/sections/user_guide/cli/drivers/mpas_init/help.out
index 7e51e71d4..c2f743422 100644
--- a/docs/sections/user_guide/cli/drivers/mpas_init/help.out
+++ b/docs/sections/user_guide/cli/drivers/mpas_init/help.out
@@ -1,4 +1,4 @@
-usage: uw mpas_init [-h] [--version] TASK ...
+usage: uw mpas_init [-h] [--version] [--show-schema] TASK ...
Execute mpas_init tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.cmd b/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.cmd
new file mode 100644
index 000000000..32253d17d
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.cmd
@@ -0,0 +1,2 @@
+uw mpas_init --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.out b/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.out
new file mode 100644
index 000000000..998687f90
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/mpas_init/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "mpas_init": {
+ "additionalProperties": false,
+ "properties": {
+ "boundary_conditions": {
+ "additionalProperties": false,
+ "properties": {
+ "interval_hours": {
+ "minimum": 1,
+...
+ "streams"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "mpas_init"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/orog_gsl.rst b/docs/sections/user_guide/cli/drivers/orog_gsl.rst
index 7f2490b2a..d589651b6 100644
--- a/docs/sections/user_guide/cli/drivers/orog_gsl.rst
+++ b/docs/sections/user_guide/cli/drivers/orog_gsl.rst
@@ -52,3 +52,11 @@ Its contents are described in section :ref:`orog_gsl_yaml`.
$ uw orog_gsl run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: orog_gsl/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: orog_gsl/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/orog_gsl/help.out b/docs/sections/user_guide/cli/drivers/orog_gsl/help.out
index 744e588ff..b49285d3d 100644
--- a/docs/sections/user_guide/cli/drivers/orog_gsl/help.out
+++ b/docs/sections/user_guide/cli/drivers/orog_gsl/help.out
@@ -1,4 +1,4 @@
-usage: uw orog_gsl [-h] [--version] TASK ...
+usage: uw orog_gsl [-h] [--version] [--show-schema] TASK ...
Execute orog_gsl tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.cmd b/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.cmd
new file mode 100644
index 000000000..3bdf61aac
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.cmd
@@ -0,0 +1,2 @@
+uw orog_gsl --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.out b/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.out
new file mode 100644
index 000000000..36a92dfb9
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/orog_gsl/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "orog_gsl": {
+ "additionalProperties": false,
+ "properties": {
+ "config": {
+ "additionalProperties": false,
+ "properties": {
+ "halo": {
+ "type": "integer"
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "orog_gsl"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/sfc_climo_gen.rst b/docs/sections/user_guide/cli/drivers/sfc_climo_gen.rst
index 559316077..fe8939667 100644
--- a/docs/sections/user_guide/cli/drivers/sfc_climo_gen.rst
+++ b/docs/sections/user_guide/cli/drivers/sfc_climo_gen.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`sfc_climo_gen_yaml`.
.. code-block:: text
$ uw sfc_climo_gen provisioned_rundir --config-file config.yaml --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: sfc_climo_gen/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: sfc_climo_gen/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/sfc_climo_gen/help.out b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/help.out
index 28b619718..f6170ae46 100644
--- a/docs/sections/user_guide/cli/drivers/sfc_climo_gen/help.out
+++ b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/help.out
@@ -1,4 +1,4 @@
-usage: uw sfc_climo_gen [-h] [--version] TASK ...
+usage: uw sfc_climo_gen [-h] [--version] [--show-schema] TASK ...
Execute sfc_climo_gen tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.cmd b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.cmd
new file mode 100644
index 000000000..918a4c80b
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.cmd
@@ -0,0 +1,2 @@
+uw sfc_climo_gen --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.out b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.out
new file mode 100644
index 000000000..9cafdced3
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/sfc_climo_gen/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "sfc_climo_gen": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "sfc_climo_gen"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/shave.rst b/docs/sections/user_guide/cli/drivers/shave.rst
index d3bef10f2..ff528c5ab 100644
--- a/docs/sections/user_guide/cli/drivers/shave.rst
+++ b/docs/sections/user_guide/cli/drivers/shave.rst
@@ -52,3 +52,11 @@ Its contents are described in section :ref:`shave_yaml`.
$ uw shave run --config-file config.yaml --batch --dry-run
.. include:: /shared/key_path.rst
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: shave/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: shave/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/shave/help.out b/docs/sections/user_guide/cli/drivers/shave/help.out
index 74056e0aa..7cd91374e 100644
--- a/docs/sections/user_guide/cli/drivers/shave/help.out
+++ b/docs/sections/user_guide/cli/drivers/shave/help.out
@@ -1,4 +1,4 @@
-usage: uw shave [-h] [--version] TASK ...
+usage: uw shave [-h] [--version] [--show-schema] TASK ...
Execute shave tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/shave/show-schema.cmd b/docs/sections/user_guide/cli/drivers/shave/show-schema.cmd
new file mode 100644
index 000000000..783cabcfe
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/shave/show-schema.cmd
@@ -0,0 +1,2 @@
+uw shave --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/shave/show-schema.out b/docs/sections/user_guide/cli/drivers/shave/show-schema.out
new file mode 100644
index 000000000..b18e04f5f
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/shave/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "shave": {
+ "additionalProperties": false,
+ "properties": {
+ "config": {
+ "additionalProperties": false,
+ "properties": {
+ "input_grid_file": {
+ "type": "string"
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "shave"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/ungrib.rst b/docs/sections/user_guide/cli/drivers/ungrib.rst
index a53a07a63..df25b4e1f 100644
--- a/docs/sections/user_guide/cli/drivers/ungrib.rst
+++ b/docs/sections/user_guide/cli/drivers/ungrib.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`ungrib_yaml`.
.. code-block:: text
$ uw ungrib provisioned_rundir --config-file config.yaml --cycle 2021-04-01T12 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: ungrib/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: ungrib/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/ungrib/help.out b/docs/sections/user_guide/cli/drivers/ungrib/help.out
index 8d4fe4816..252272e05 100644
--- a/docs/sections/user_guide/cli/drivers/ungrib/help.out
+++ b/docs/sections/user_guide/cli/drivers/ungrib/help.out
@@ -1,4 +1,4 @@
-usage: uw ungrib [-h] [--version] TASK ...
+usage: uw ungrib [-h] [--version] [--show-schema] TASK ...
Execute ungrib tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/ungrib/show-schema.cmd b/docs/sections/user_guide/cli/drivers/ungrib/show-schema.cmd
new file mode 100644
index 000000000..04ae66e66
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/ungrib/show-schema.cmd
@@ -0,0 +1,2 @@
+uw ungrib --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/ungrib/show-schema.out b/docs/sections/user_guide/cli/drivers/ungrib/show-schema.out
new file mode 100644
index 000000000..01684d40b
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/ungrib/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "ungrib": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "vtable"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "ungrib"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/drivers/upp.rst b/docs/sections/user_guide/cli/drivers/upp.rst
index 22b06ecb4..ae8d24eb4 100644
--- a/docs/sections/user_guide/cli/drivers/upp.rst
+++ b/docs/sections/user_guide/cli/drivers/upp.rst
@@ -58,3 +58,11 @@ Its contents are described in depth in section :ref:`upp_yaml`.
.. code-block:: text
$ uw upp provisioned_rundir --config-file config.yaml --cycle 2024-05-06T12 --leadtime 6 --batch
+
+* Specifying the ``--show-schema`` flag, with no other options, prints the driver's schema:
+
+.. literalinclude:: upp/show-schema.cmd
+ :language: text
+ :emphasize-lines: 1
+.. literalinclude:: upp/show-schema.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/drivers/upp/help.out b/docs/sections/user_guide/cli/drivers/upp/help.out
index 68d5748b7..7163d3f71 100644
--- a/docs/sections/user_guide/cli/drivers/upp/help.out
+++ b/docs/sections/user_guide/cli/drivers/upp/help.out
@@ -1,4 +1,4 @@
-usage: uw upp [-h] [--version] TASK ...
+usage: uw upp [-h] [--version] [--show-schema] TASK ...
Execute upp tasks
@@ -7,6 +7,8 @@ Optional arguments:
Show help and exit
--version
Show version info and exit
+ --show-schema
+ Show driver schema and exit
Positional arguments:
TASK
diff --git a/docs/sections/user_guide/cli/drivers/upp/show-schema.cmd b/docs/sections/user_guide/cli/drivers/upp/show-schema.cmd
new file mode 100644
index 000000000..c0a1b290d
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/upp/show-schema.cmd
@@ -0,0 +1,2 @@
+uw upp --show-schema >schema
+head schema && echo ... && tail schema
diff --git a/docs/sections/user_guide/cli/drivers/upp/show-schema.out b/docs/sections/user_guide/cli/drivers/upp/show-schema.out
new file mode 100644
index 000000000..11be8c436
--- /dev/null
+++ b/docs/sections/user_guide/cli/drivers/upp/show-schema.out
@@ -0,0 +1,21 @@
+{
+ "properties": {
+ "upp": {
+ "additionalProperties": false,
+ "properties": {
+ "execution": {
+ "additionalProperties": false,
+ "properties": {
+ "batchargs": {
+ "additionalProperties": true,
+...
+ "rundir"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "upp"
+ ],
+ "type": "object"
+}
diff --git a/docs/sections/user_guide/cli/tools/execute/rand.py b/docs/sections/user_guide/cli/tools/execute/rand.py
index 2a8e10b57..99e5853c9 100644
--- a/docs/sections/user_guide/cli/tools/execute/rand.py
+++ b/docs/sections/user_guide/cli/tools/execute/rand.py
@@ -23,6 +23,6 @@ def randfile(self):
with open(path, "w", encoding="utf-8") as f:
print(randint(self.config["lo"], self.config["hi"]), file=f)
- @property
- def driver_name(self):
+ @classmethod
+ def driver_name(cls):
return "rand"
diff --git a/docs/sections/user_guide/cli/tools/file.rst b/docs/sections/user_guide/cli/tools/file.rst
deleted file mode 100644
index b7d34cbe3..000000000
--- a/docs/sections/user_guide/cli/tools/file.rst
+++ /dev/null
@@ -1,97 +0,0 @@
-``file``
-========
-
-The ``uw`` mode for handling filesystem files.
-
-.. literalinclude:: file/help.cmd
- :emphasize-lines: 1
-.. literalinclude:: file/help.out
- :language: text
-
-.. _cli_file_copy_examples:
-
-``copy``
---------
-
-The ``copy`` action stages files in a target directory by copying files. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`file block `.
-
-.. literalinclude:: file/copy-help.cmd
- :emphasize-lines: 1
-.. literalinclude:: file/copy-help.out
- :language: text
-
-Examples
-^^^^^^^^
-
-Given ``copy-config.yaml`` containing
-
-.. literalinclude:: file/copy-config.yaml
- :language: yaml
-.. literalinclude:: file/copy-exec.cmd
- :emphasize-lines: 2
-.. literalinclude:: file/copy-exec.out
- :language: text
-
-Here, ``foo`` and ``bar`` are copies of their respective source files.
-
-The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:
-
-.. literalinclude:: file/copy-config-timedep.yaml
- :language: yaml
-.. literalinclude:: file/copy-exec-timedep.cmd
- :emphasize-lines: 2
-.. literalinclude:: file/copy-exec-timedep.out
- :language: text
-
-The ``--target-dir`` option is optional when all destination paths are absolute, and will never be applied to absolute destination paths. If any destination paths are relative, however, it is an error not to provide a target directory:
-
-.. literalinclude:: file/copy-config.yaml
- :language: yaml
-.. literalinclude:: file/copy-exec-no-target-dir-err.cmd
- :emphasize-lines: 1
-.. literalinclude:: file/copy-exec-no-target-dir-err.out
- :language: text
-
-.. _cli_file_link_examples:
-
-``link``
---------
-
-The ``link`` action stages files in a target directory by linking files, directories, or other symbolic links. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`file block `.
-
-.. literalinclude:: file/link-help.cmd
- :emphasize-lines: 1
-.. literalinclude:: file/link-help.out
- :language: text
-
-Examples
-^^^^^^^^
-
-Given ``link-config.yaml`` containing
-
-.. literalinclude:: file/link-config.yaml
- :language: yaml
-.. literalinclude:: file/link-exec.cmd
- :emphasize-lines: 2
-.. literalinclude:: file/link-exec.out
- :language: text
-
-Here, ``foo`` and ``bar`` are symbolic links.
-
-The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:
-
-.. literalinclude:: file/link-config-timedep.yaml
- :language: yaml
-.. literalinclude:: file/link-exec-timedep.cmd
- :emphasize-lines: 2
-.. literalinclude:: file/link-exec-timedep.out
- :language: text
-
-The ``--target-dir`` option is optional when all linkname paths are absolute, and will never be applied to absolute linkname paths. If any linkname paths are relative, however, it is an error not to provide a target directory:
-
-.. literalinclude:: file/link-config.yaml
- :language: yaml
-.. literalinclude:: file/link-exec-no-target-dir-err.cmd
- :emphasize-lines: 1
-.. literalinclude:: file/link-exec-no-target-dir-err.out
- :language: text
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.cmd b/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.cmd
deleted file mode 100644
index ed4fd1f90..000000000
--- a/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.cmd
+++ /dev/null
@@ -1 +0,0 @@
-uw file copy --config-file copy-config.yaml config files
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.out b/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.out
deleted file mode 100644
index 388dc0318..000000000
--- a/docs/sections/user_guide/cli/tools/file/copy-exec-no-target-dir-err.out
+++ /dev/null
@@ -1 +0,0 @@
-[2024-07-26T21:51:23] ERROR Relative path 'foo' requires the target directory to be specified
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec-timedep.cmd b/docs/sections/user_guide/cli/tools/file/copy-exec-timedep.cmd
deleted file mode 100644
index cec34d0a7..000000000
--- a/docs/sections/user_guide/cli/tools/file/copy-exec-timedep.cmd
+++ /dev/null
@@ -1,4 +0,0 @@
-rm -rf copy-dst-timedep
-uw file copy --target-dir copy-dst-timedep --config-file copy-config-timedep.yaml --cycle 2024-05-29T12 --leadtime 6 config files
-echo
-tree copy-dst-timedep
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec.cmd b/docs/sections/user_guide/cli/tools/file/copy-exec.cmd
deleted file mode 100644
index f74e38e33..000000000
--- a/docs/sections/user_guide/cli/tools/file/copy-exec.cmd
+++ /dev/null
@@ -1,4 +0,0 @@
-rm -rf copy-dst
-uw file copy --target-dir copy-dst --config-file copy-config.yaml config files
-echo
-tree copy-dst
diff --git a/docs/sections/user_guide/cli/tools/file/copy-help.cmd b/docs/sections/user_guide/cli/tools/file/copy-help.cmd
deleted file mode 100644
index 5a5188caa..000000000
--- a/docs/sections/user_guide/cli/tools/file/copy-help.cmd
+++ /dev/null
@@ -1 +0,0 @@
-uw file copy --help
diff --git a/docs/sections/user_guide/cli/tools/file/help.cmd b/docs/sections/user_guide/cli/tools/file/help.cmd
deleted file mode 100644
index e89f12e45..000000000
--- a/docs/sections/user_guide/cli/tools/file/help.cmd
+++ /dev/null
@@ -1 +0,0 @@
-uw file --help
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.cmd b/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.cmd
deleted file mode 100644
index 83b52f5e5..000000000
--- a/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.cmd
+++ /dev/null
@@ -1 +0,0 @@
-uw file link --config-file link-config.yaml config files
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.out b/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.out
deleted file mode 100644
index 388dc0318..000000000
--- a/docs/sections/user_guide/cli/tools/file/link-exec-no-target-dir-err.out
+++ /dev/null
@@ -1 +0,0 @@
-[2024-07-26T21:51:23] ERROR Relative path 'foo' requires the target directory to be specified
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec-timedep.cmd b/docs/sections/user_guide/cli/tools/file/link-exec-timedep.cmd
deleted file mode 100644
index 915899c83..000000000
--- a/docs/sections/user_guide/cli/tools/file/link-exec-timedep.cmd
+++ /dev/null
@@ -1,4 +0,0 @@
-rm -rf link-dst-timedep
-uw file link --target-dir link-dst-timedep --config-file link-config-timedep.yaml --cycle 2024-05-29T12 --leadtime 6 config files
-echo
-tree link-dst-timedep
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec.cmd b/docs/sections/user_guide/cli/tools/file/link-exec.cmd
deleted file mode 100644
index 81da785b4..000000000
--- a/docs/sections/user_guide/cli/tools/file/link-exec.cmd
+++ /dev/null
@@ -1,4 +0,0 @@
-rm -rf link-dst
-uw file link --target-dir link-dst --config-file link-config.yaml config files
-echo
-tree link-dst
diff --git a/docs/sections/user_guide/cli/tools/file/link-help.cmd b/docs/sections/user_guide/cli/tools/file/link-help.cmd
deleted file mode 100644
index a3ce9a824..000000000
--- a/docs/sections/user_guide/cli/tools/file/link-help.cmd
+++ /dev/null
@@ -1 +0,0 @@
-uw file link --help
diff --git a/docs/sections/user_guide/cli/tools/fs.rst b/docs/sections/user_guide/cli/tools/fs.rst
new file mode 100644
index 000000000..cb61dd7bf
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs.rst
@@ -0,0 +1,135 @@
+``fs``
+======
+
+.. _cli_fs_mode:
+
+The ``uw`` mode for handling filesystem items (files and directories).
+
+.. literalinclude:: fs/help.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/help.out
+ :language: text
+
+``copy``
+--------
+
+The ``copy`` action stages files in a target directory by copying files. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`file block `.
+
+.. literalinclude:: fs/copy-help.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/copy-help.out
+ :language: text
+
+Examples
+^^^^^^^^
+
+Given ``copy-config.yaml`` containing
+
+.. literalinclude:: fs/copy-config.yaml
+ :language: yaml
+.. literalinclude:: fs/copy-exec.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/copy-exec.out
+ :language: text
+
+Here, ``foo`` and ``bar`` are copies of their respective source files.
+
+The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:
+
+.. literalinclude:: fs/copy-config-timedep.yaml
+ :language: yaml
+.. literalinclude:: fs/copy-exec-timedep.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/copy-exec-timedep.out
+ :language: text
+
+The ``--target-dir`` option need not be specified when all destination paths are absolute, and will never be applied to absolute destination paths. If any destination paths are relative, however, it is an error not to provide a target directory:
+
+.. literalinclude:: fs/copy-config.yaml
+ :language: yaml
+.. literalinclude:: fs/copy-exec-no-target-dir-err.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/copy-exec-no-target-dir-err.out
+ :language: text
+
+``link``
+--------
+
+The ``link`` action stages files in a target directory by linking files, directories, or other symbolic links. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`file block `.
+
+.. literalinclude:: fs/link-help.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/link-help.out
+ :language: text
+
+Examples
+^^^^^^^^
+
+Given ``link-config.yaml`` containing
+
+.. literalinclude:: fs/link-config.yaml
+ :language: yaml
+.. literalinclude:: fs/link-exec.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/link-exec.out
+ :language: text
+
+Here, ``foo`` and ``bar`` are symbolic links.
+
+The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:
+
+.. literalinclude:: fs/link-config-timedep.yaml
+ :language: yaml
+.. literalinclude:: fs/link-exec-timedep.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/link-exec-timedep.out
+ :language: text
+
+The ``--target-dir`` option need not be specified when all linkname paths are absolute, and will never be applied to absolute linkname paths. If any linkname paths are relative, however, it is an error not to provide a target directory:
+
+.. literalinclude:: fs/link-config.yaml
+ :language: yaml
+.. literalinclude:: fs/link-exec-no-target-dir-err.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/link-exec-no-target-dir-err.out
+ :language: text
+
+``makedirs``
+------------
+
+The ``makedirs`` action creates directories. Any ``KEY`` positional arguments are used to navigate, in the order given, from the top of the config to the :ref:`makedirs block `.
+
+.. literalinclude:: fs/makedirs-help.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/makedirs-help.out
+ :language: text
+
+Examples
+^^^^^^^^
+
+Given ``makedirs-config.yaml`` containing
+
+.. literalinclude:: fs/makedirs-config.yaml
+ :language: yaml
+.. literalinclude:: fs/makedirs-exec.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/makedirs-exec.out
+ :language: text
+
+The ``--cycle`` and ``--leadtime`` options can be used to make Python ``datetime`` and ``timedelta`` objects, respectively, available for use in Jinja2 expression in the config. For example:
+
+.. literalinclude:: fs/makedirs-config-timedep.yaml
+ :language: yaml
+.. literalinclude:: fs/makedirs-exec-timedep.cmd
+ :emphasize-lines: 2
+.. literalinclude:: fs/makedirs-exec-timedep.out
+ :language: text
+
+The ``--target-dir`` option need not be specified when all directory paths are absolute, and will never be applied to absolute paths. If any paths are relative, however, it is an error not to provide a target directory:
+
+.. literalinclude:: fs/makedirs-config.yaml
+ :language: yaml
+.. literalinclude:: fs/makedirs-exec-no-target-dir-err.cmd
+ :emphasize-lines: 1
+.. literalinclude:: fs/makedirs-exec-no-target-dir-err.out
+ :language: text
diff --git a/docs/sections/user_guide/cli/tools/file/.gitignore b/docs/sections/user_guide/cli/tools/fs/.gitignore
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/.gitignore
rename to docs/sections/user_guide/cli/tools/fs/.gitignore
diff --git a/docs/sections/user_guide/cli/tools/file/Makefile b/docs/sections/user_guide/cli/tools/fs/Makefile
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/Makefile
rename to docs/sections/user_guide/cli/tools/fs/Makefile
diff --git a/docs/sections/user_guide/cli/tools/file/copy-config-timedep.yaml b/docs/sections/user_guide/cli/tools/fs/copy-config-timedep.yaml
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/copy-config-timedep.yaml
rename to docs/sections/user_guide/cli/tools/fs/copy-config-timedep.yaml
diff --git a/docs/sections/user_guide/cli/tools/file/copy-config.yaml b/docs/sections/user_guide/cli/tools/fs/copy-config.yaml
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/copy-config.yaml
rename to docs/sections/user_guide/cli/tools/fs/copy-config.yaml
diff --git a/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.cmd b/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.cmd
new file mode 100644
index 000000000..fb3d8b83f
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.cmd
@@ -0,0 +1 @@
+uw fs copy --config-file copy-config.yaml config files
diff --git a/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.out b/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.out
new file mode 100644
index 000000000..02c57878d
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/copy-exec-no-target-dir-err.out
@@ -0,0 +1,3 @@
+[2024-08-14T15:19:59] INFO Validating config against internal schema: files-to-stage
+[2024-08-14T15:19:59] INFO 0 UW schema-validation errors found
+[2024-08-14T15:19:59] ERROR Relative path 'foo' requires the target directory to be specified
diff --git a/docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.cmd b/docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.cmd
new file mode 100644
index 000000000..0f2a1911b
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.cmd
@@ -0,0 +1,4 @@
+rm -rf copy-dst-timedep
+uw fs copy --target-dir copy-dst-timedep --config-file copy-config-timedep.yaml --cycle 2024-05-29T12 --leadtime 6 config files
+echo
+tree copy-dst-timedep
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec-timedep.out b/docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.out
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/copy-exec-timedep.out
rename to docs/sections/user_guide/cli/tools/fs/copy-exec-timedep.out
diff --git a/docs/sections/user_guide/cli/tools/fs/copy-exec.cmd b/docs/sections/user_guide/cli/tools/fs/copy-exec.cmd
new file mode 100644
index 000000000..175451030
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/copy-exec.cmd
@@ -0,0 +1,4 @@
+rm -rf copy-dst
+uw fs copy --target-dir copy-dst --config-file copy-config.yaml config files
+echo
+tree copy-dst
diff --git a/docs/sections/user_guide/cli/tools/file/copy-exec.out b/docs/sections/user_guide/cli/tools/fs/copy-exec.out
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/copy-exec.out
rename to docs/sections/user_guide/cli/tools/fs/copy-exec.out
diff --git a/docs/sections/user_guide/cli/tools/fs/copy-help.cmd b/docs/sections/user_guide/cli/tools/fs/copy-help.cmd
new file mode 100644
index 000000000..4806f822a
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/copy-help.cmd
@@ -0,0 +1 @@
+uw fs copy --help
diff --git a/docs/sections/user_guide/cli/tools/file/copy-help.out b/docs/sections/user_guide/cli/tools/fs/copy-help.out
similarity index 73%
rename from docs/sections/user_guide/cli/tools/file/copy-help.out
rename to docs/sections/user_guide/cli/tools/fs/copy-help.out
index bbc885512..ce73007a6 100644
--- a/docs/sections/user_guide/cli/tools/file/copy-help.out
+++ b/docs/sections/user_guide/cli/tools/fs/copy-help.out
@@ -1,7 +1,7 @@
-usage: uw file copy [-h] [--version] [--config-file PATH] [--target-dir PATH]
- [--cycle CYCLE] [--leadtime LEADTIME] [--dry-run]
- [--quiet] [--verbose]
- [KEY ...]
+usage: uw fs copy [-h] [--version] [--config-file PATH] [--target-dir PATH]
+ [--cycle CYCLE] [--leadtime LEADTIME] [--dry-run] [--quiet]
+ [--verbose]
+ [KEY ...]
Copy files
diff --git a/docs/sections/user_guide/cli/tools/fs/help.cmd b/docs/sections/user_guide/cli/tools/fs/help.cmd
new file mode 100644
index 000000000..7e46d94b9
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/help.cmd
@@ -0,0 +1 @@
+uw fs --help
diff --git a/docs/sections/user_guide/cli/tools/file/help.out b/docs/sections/user_guide/cli/tools/fs/help.out
similarity index 60%
rename from docs/sections/user_guide/cli/tools/file/help.out
rename to docs/sections/user_guide/cli/tools/fs/help.out
index 70252750d..6d573c404 100644
--- a/docs/sections/user_guide/cli/tools/file/help.out
+++ b/docs/sections/user_guide/cli/tools/fs/help.out
@@ -1,6 +1,6 @@
-usage: uw file [-h] [--version] ACTION ...
+usage: uw fs [-h] [--version] ACTION ...
-Handle files
+Handle filesystem items (files and directories)
Optional arguments:
-h, --help
@@ -14,3 +14,5 @@ Positional arguments:
Copy files
link
Link files
+ makedirs
+ Make directories
diff --git a/docs/sections/user_guide/cli/tools/file/link-config-timedep.yaml b/docs/sections/user_guide/cli/tools/fs/link-config-timedep.yaml
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/link-config-timedep.yaml
rename to docs/sections/user_guide/cli/tools/fs/link-config-timedep.yaml
diff --git a/docs/sections/user_guide/cli/tools/file/link-config.yaml b/docs/sections/user_guide/cli/tools/fs/link-config.yaml
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/link-config.yaml
rename to docs/sections/user_guide/cli/tools/fs/link-config.yaml
diff --git a/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.cmd b/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.cmd
new file mode 100644
index 000000000..8446a4e8b
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.cmd
@@ -0,0 +1 @@
+uw fs link --config-file link-config.yaml config files
diff --git a/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.out b/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.out
new file mode 100644
index 000000000..03c1247f5
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/link-exec-no-target-dir-err.out
@@ -0,0 +1,3 @@
+[2024-08-14T15:19:57] INFO Validating config against internal schema: files-to-stage
+[2024-08-14T15:19:57] INFO 0 UW schema-validation errors found
+[2024-08-14T15:19:57] ERROR Relative path 'foo' requires the target directory to be specified
diff --git a/docs/sections/user_guide/cli/tools/fs/link-exec-timedep.cmd b/docs/sections/user_guide/cli/tools/fs/link-exec-timedep.cmd
new file mode 100644
index 000000000..f3e240397
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/link-exec-timedep.cmd
@@ -0,0 +1,4 @@
+rm -rf link-dst-timedep
+uw fs link --target-dir link-dst-timedep --config-file link-config-timedep.yaml --cycle 2024-05-29T12 --leadtime 6 config files
+echo
+tree link-dst-timedep
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec-timedep.out b/docs/sections/user_guide/cli/tools/fs/link-exec-timedep.out
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/link-exec-timedep.out
rename to docs/sections/user_guide/cli/tools/fs/link-exec-timedep.out
diff --git a/docs/sections/user_guide/cli/tools/fs/link-exec.cmd b/docs/sections/user_guide/cli/tools/fs/link-exec.cmd
new file mode 100644
index 000000000..f4f14059b
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/link-exec.cmd
@@ -0,0 +1,4 @@
+rm -rf link-dst
+uw fs link --target-dir link-dst --config-file link-config.yaml config files
+echo
+tree link-dst
diff --git a/docs/sections/user_guide/cli/tools/file/link-exec.out b/docs/sections/user_guide/cli/tools/fs/link-exec.out
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/link-exec.out
rename to docs/sections/user_guide/cli/tools/fs/link-exec.out
diff --git a/docs/sections/user_guide/cli/tools/fs/link-help.cmd b/docs/sections/user_guide/cli/tools/fs/link-help.cmd
new file mode 100644
index 000000000..b36d8d6d2
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/link-help.cmd
@@ -0,0 +1 @@
+uw fs link --help
diff --git a/docs/sections/user_guide/cli/tools/file/link-help.out b/docs/sections/user_guide/cli/tools/fs/link-help.out
similarity index 73%
rename from docs/sections/user_guide/cli/tools/file/link-help.out
rename to docs/sections/user_guide/cli/tools/fs/link-help.out
index 8734b5ad4..be07a9eec 100644
--- a/docs/sections/user_guide/cli/tools/file/link-help.out
+++ b/docs/sections/user_guide/cli/tools/fs/link-help.out
@@ -1,7 +1,7 @@
-usage: uw file link [-h] [--version] [--config-file PATH] [--target-dir PATH]
- [--cycle CYCLE] [--leadtime LEADTIME] [--dry-run]
- [--quiet] [--verbose]
- [KEY ...]
+usage: uw fs link [-h] [--version] [--config-file PATH] [--target-dir PATH]
+ [--cycle CYCLE] [--leadtime LEADTIME] [--dry-run] [--quiet]
+ [--verbose]
+ [KEY ...]
Link files
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-config-timedep.yaml b/docs/sections/user_guide/cli/tools/fs/makedirs-config-timedep.yaml
new file mode 100644
index 000000000..abf1001b8
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-config-timedep.yaml
@@ -0,0 +1,8 @@
+config:
+ makedirs:
+ - foo/{{ yyyymmdd }}/{{ hh }}/{{ nnn }}/bar
+ - baz/{{ yyyymmdd }}/{{ hh }}/{{ nnn }}/qux
+yyyymmdd: "{{ cycle.strftime('%Y%m%d') }}"
+hh: "{{ cycle.strftime('%H') }}"
+nnn: "{{ '%03d' % (leadtime.total_seconds() // 3600) }}"
+validtime: "{{ (cycle + leadtime).strftime('%Y-%m-%dT%H') }}"
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-config.yaml b/docs/sections/user_guide/cli/tools/fs/makedirs-config.yaml
new file mode 100644
index 000000000..6bf12f29a
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-config.yaml
@@ -0,0 +1,4 @@
+config:
+ makedirs:
+ - foo
+ - bar
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.cmd b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.cmd
new file mode 100644
index 000000000..80ff4150e
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.cmd
@@ -0,0 +1 @@
+uw fs makedirs --config-file makedirs-config.yaml config
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.out b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.out
new file mode 100644
index 000000000..ceefc693e
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-no-target-dir-err.out
@@ -0,0 +1,3 @@
+[2024-08-14T15:19:58] INFO Validating config against internal schema: makedirs
+[2024-08-14T15:19:58] INFO 0 UW schema-validation errors found
+[2024-08-14T15:19:58] ERROR Relative path 'foo' requires the target directory to be specified
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.cmd b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.cmd
new file mode 100644
index 000000000..fa30614ec
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.cmd
@@ -0,0 +1,4 @@
+rm -rf makedirs-parent-timedep
+uw fs makedirs --target-dir makedirs-parent-timedep --config-file makedirs-config-timedep.yaml --cycle 2024-05-29T12 --leadtime 6 config
+echo
+tree -F makedirs-parent-timedep
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.out b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.out
new file mode 100644
index 000000000..a89e33a29
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec-timedep.out
@@ -0,0 +1,29 @@
+[2024-08-12T04:35:49] INFO Validating config against internal schema: makedirs
+[2024-08-12T04:35:49] INFO 0 UW schema-validation errors found
+[2024-08-12T04:35:49] INFO Directories: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directories: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/foo/20240529/12/006/bar: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/foo/20240529/12/006/bar: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/foo/20240529/12/006/bar: Requirement(s) ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/foo/20240529/12/006/bar: Executing
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/foo/20240529/12/006/bar: Final state: Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/baz/20240529/12/006/qux: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/baz/20240529/12/006/qux: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/baz/20240529/12/006/qux: Requirement(s) ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/baz/20240529/12/006/qux: Executing
+[2024-08-12T04:35:49] INFO Directory makedirs-parent-timedep/baz/20240529/12/006/qux: Final state: Ready
+[2024-08-12T04:35:49] INFO Directories: Final state: Ready
+
+makedirs-parent-timedep/
+├── baz/
+│ └── 20240529/
+│ └── 12/
+│ └── 006/
+│ └── qux/
+└── foo/
+ └── 20240529/
+ └── 12/
+ └── 006/
+ └── bar/
+
+11 directories, 0 files
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec.cmd b/docs/sections/user_guide/cli/tools/fs/makedirs-exec.cmd
new file mode 100644
index 000000000..b99d8c3f4
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec.cmd
@@ -0,0 +1,4 @@
+rm -rf makedirs-parent
+uw fs makedirs --target-dir makedirs-parent --config-file makedirs-config.yaml config
+echo
+tree -F makedirs-parent
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-exec.out b/docs/sections/user_guide/cli/tools/fs/makedirs-exec.out
new file mode 100644
index 000000000..376518b59
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-exec.out
@@ -0,0 +1,21 @@
+[2024-08-12T04:35:49] INFO Validating config against internal schema: makedirs
+[2024-08-12T04:35:49] INFO 0 UW schema-validation errors found
+[2024-08-12T04:35:49] INFO Directories: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directories: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/foo: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/foo: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/foo: Requirement(s) ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/foo: Executing
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/foo: Final state: Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/bar: Initial state: Not Ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/bar: Checking requirements
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/bar: Requirement(s) ready
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/bar: Executing
+[2024-08-12T04:35:49] INFO Directory makedirs-parent/bar: Final state: Ready
+[2024-08-12T04:35:49] INFO Directories: Final state: Ready
+
+makedirs-parent/
+├── bar/
+└── foo/
+
+3 directories, 0 files
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-help.cmd b/docs/sections/user_guide/cli/tools/fs/makedirs-help.cmd
new file mode 100644
index 000000000..f9cacc5e3
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-help.cmd
@@ -0,0 +1 @@
+uw fs makedirs --help
diff --git a/docs/sections/user_guide/cli/tools/fs/makedirs-help.out b/docs/sections/user_guide/cli/tools/fs/makedirs-help.out
new file mode 100644
index 000000000..0e80d2f16
--- /dev/null
+++ b/docs/sections/user_guide/cli/tools/fs/makedirs-help.out
@@ -0,0 +1,28 @@
+usage: uw fs makedirs [-h] [--version] [--config-file PATH]
+ [--target-dir PATH] [--cycle CYCLE]
+ [--leadtime LEADTIME] [--dry-run] [--quiet] [--verbose]
+ [KEY ...]
+
+Make directories
+
+Optional arguments:
+ -h, --help
+ Show help and exit
+ --version
+ Show version info and exit
+ --config-file PATH, -c PATH
+ Path to UW YAML config file (default: read from stdin)
+ --target-dir PATH
+ Root directory for relative destination paths
+ --cycle CYCLE
+ The cycle in ISO8601 format (e.g. 2024-08-12T00)
+ --leadtime LEADTIME
+ The leadtime as hours[:minutes[:seconds]]
+ --dry-run
+ Only log info, making no changes
+ --quiet, -q
+ Print no logging messages
+ --verbose, -v
+ Print all logging messages
+ KEY
+ YAML key leading to file dst/src block
diff --git a/docs/sections/user_guide/cli/tools/file/src/20240529/12/006/baz b/docs/sections/user_guide/cli/tools/fs/src/20240529/12/006/baz
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/src/20240529/12/006/baz
rename to docs/sections/user_guide/cli/tools/fs/src/20240529/12/006/baz
diff --git a/docs/sections/user_guide/cli/tools/file/src/bar b/docs/sections/user_guide/cli/tools/fs/src/bar
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/src/bar
rename to docs/sections/user_guide/cli/tools/fs/src/bar
diff --git a/docs/sections/user_guide/cli/tools/file/src/foo b/docs/sections/user_guide/cli/tools/fs/src/foo
similarity index 100%
rename from docs/sections/user_guide/cli/tools/file/src/foo
rename to docs/sections/user_guide/cli/tools/fs/src/foo
diff --git a/docs/sections/user_guide/cli/tools/index.rst b/docs/sections/user_guide/cli/tools/index.rst
index 27d52c00f..238759a8b 100644
--- a/docs/sections/user_guide/cli/tools/index.rst
+++ b/docs/sections/user_guide/cli/tools/index.rst
@@ -6,6 +6,6 @@ Tools
config
execute
- file
+ fs
rocoto
template
diff --git a/docs/sections/user_guide/yaml/files.rst b/docs/sections/user_guide/yaml/files.rst
index 878ffd382..0d34ba38d 100644
--- a/docs/sections/user_guide/yaml/files.rst
+++ b/docs/sections/user_guide/yaml/files.rst
@@ -3,7 +3,7 @@
File Blocks
===========
-File blocks define files to be staged in a target directory as copies or symbolic links. Keys in such blocks specify destination paths relative to the target directory, and values specify source paths.
+File blocks define files to be staged in a target directory as copies or symbolic links. Keys in such blocks specify either absolute destination paths, or destination paths relative to the target directory. Values specify source paths.
Example block:
diff --git a/docs/sections/user_guide/yaml/index.rst b/docs/sections/user_guide/yaml/index.rst
index 8da086673..e8e1be934 100644
--- a/docs/sections/user_guide/yaml/index.rst
+++ b/docs/sections/user_guide/yaml/index.rst
@@ -5,9 +5,10 @@ UW YAML
:maxdepth: 1
components/index
- platform
execution
files
- updating_values
+ makedirs
+ platform
rocoto
tags
+ updating_values
diff --git a/docs/sections/user_guide/yaml/makedirs.rst b/docs/sections/user_guide/yaml/makedirs.rst
new file mode 100644
index 000000000..e1744666a
--- /dev/null
+++ b/docs/sections/user_guide/yaml/makedirs.rst
@@ -0,0 +1,22 @@
+.. _makedirs_yaml:
+
+Directory Blocks
+================
+
+Directory blocks define a sequence of one or more directories to be created, nested under a ``makedirs:`` key. Each value is either an absolute path, or a path relative to the target directory, specified either via the CLI or an API call.
+
+Example block with absolute paths:
+
+.. code-block:: yaml
+
+ makedirs:
+ - /path/to/dir1
+ - /path/to/dir2
+
+Example block with relative paths:
+
+.. code-block:: yaml
+
+ makedirs:
+ - /subdir/dir1
+ - ../../dir2
diff --git a/recipe/meta.json b/recipe/meta.json
index c37b5486b..faa658d13 100644
--- a/recipe/meta.json
+++ b/recipe/meta.json
@@ -33,5 +33,5 @@
"pyyaml =6.0.*"
]
},
- "version": "2.4.1"
+ "version": "2.4.2"
}
diff --git a/src/uwtools/api/cdeps.py b/src/uwtools/api/cdeps.py
index 0c91a6b06..f6e112309 100644
--- a/src/uwtools/api/cdeps.py
+++ b/src/uwtools/api/cdeps.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``cdeps`` driver.
"""
-from functools import partial
-
from uwtools.drivers.cdeps import CDEPS
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = CDEPS
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["CDEPS", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["CDEPS", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/chgres_cube.py b/src/uwtools/api/chgres_cube.py
index 51fd53f90..477f892e7 100644
--- a/src/uwtools/api/chgres_cube.py
+++ b/src/uwtools/api/chgres_cube.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``chgres_cube`` driver.
"""
-from functools import partial
-
from uwtools.drivers.chgres_cube import ChgresCube
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = ChgresCube
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["ChgresCube", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["ChgresCube", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/config.py b/src/uwtools/api/config.py
index 2d402b1be..ec05a0fa5 100644
--- a/src/uwtools/api/config.py
+++ b/src/uwtools/api/config.py
@@ -6,12 +6,12 @@
from pathlib import Path
from typing import Optional, Union
-from uwtools.config.formats.fieldtable import FieldTableConfig as _FieldTableConfig
-from uwtools.config.formats.ini import INIConfig as _INIConfig
-from uwtools.config.formats.nml import NMLConfig as _NMLConfig
-from uwtools.config.formats.sh import SHConfig as _SHConfig
-from uwtools.config.formats.yaml import Config as _Config
-from uwtools.config.formats.yaml import YAMLConfig as _YAMLConfig
+from uwtools.config.formats.base import Config as _Config
+from uwtools.config.formats.fieldtable import FieldTableConfig
+from uwtools.config.formats.ini import INIConfig
+from uwtools.config.formats.nml import NMLConfig
+from uwtools.config.formats.sh import SHConfig
+from uwtools.config.formats.yaml import YAMLConfig
from uwtools.config.tools import compare_configs as _compare
from uwtools.config.tools import realize_config as _realize
from uwtools.config.validator import validate_external as _validate_external
@@ -42,7 +42,7 @@ def compare(
def get_fieldtable_config(
config: Union[dict, Optional[Union[Path, str]]] = None, stdin_ok=False
-) -> _FieldTableConfig:
+) -> FieldTableConfig:
"""
Get a ``FieldTableConfig`` object.
@@ -50,13 +50,13 @@ def get_fieldtable_config(
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``FieldTableConfig`` object.
"""
- return _FieldTableConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
+ return FieldTableConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
def get_ini_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
-) -> _INIConfig:
+) -> INIConfig:
"""
Get an ``INIConfig`` object.
@@ -64,13 +64,13 @@ def get_ini_config(
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``INIConfig`` object.
"""
- return _INIConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
+ return INIConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
def get_nml_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
-) -> _NMLConfig:
+) -> NMLConfig:
"""
Get an ``NMLConfig`` object.
@@ -78,13 +78,13 @@ def get_nml_config(
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``NMLConfig`` object.
"""
- return _NMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
+ return NMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
def get_sh_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
-) -> _SHConfig:
+) -> SHConfig:
"""
Get an ``SHConfig`` object.
@@ -92,13 +92,13 @@ def get_sh_config(
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``SHConfig`` object.
"""
- return _SHConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
+ return SHConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
def get_yaml_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
-) -> _YAMLConfig:
+) -> YAMLConfig:
"""
Get a ``YAMLConfig`` object.
@@ -106,7 +106,7 @@ def get_yaml_config(
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``YAMLConfig`` object.
"""
- return _YAMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
+ return YAMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
def realize(
@@ -125,10 +125,12 @@ def realize(
"""
NB: This docstring is dynamically replaced: See realize.__doc__ definition below.
"""
+ if update_config is None and update_format is not None: # i.e. updates will be read from stdin
+ update_config = _ensure_data_source(update_config, stdin_ok)
return _realize(
input_config=_ensure_data_source(_str2path(input_config), stdin_ok),
input_format=input_format,
- update_config=_ensure_data_source(_str2path(update_config), stdin_ok),
+ update_config=_str2path(update_config),
update_format=update_format,
output_file=_str2path(output_file),
output_format=output_format,
@@ -160,7 +162,7 @@ def realize_to_dict( # pylint: disable=unused-argument
def validate(
schema_file: Union[Path, str],
- config: Optional[Union[dict, _YAMLConfig, Path, str]] = None,
+ config: Optional[Union[dict, YAMLConfig, Path, str]] = None,
stdin_ok: bool = False,
) -> bool:
"""
@@ -246,3 +248,20 @@ def validate(
""".format(
extensions=", ".join(_FORMAT.extensions())
).strip()
+
+__all__ = [
+ "FieldTableConfig",
+ "INIConfig",
+ "NMLConfig",
+ "SHConfig",
+ "YAMLConfig",
+ "compare",
+ "get_fieldtable_config",
+ "get_ini_config",
+ "get_nml_config",
+ "get_sh_config",
+ "get_yaml_config",
+ "realize",
+ "realize_to_dict",
+ "validate",
+]
diff --git a/src/uwtools/api/driver.py b/src/uwtools/api/driver.py
index 71f9e946f..4b0138489 100644
--- a/src/uwtools/api/driver.py
+++ b/src/uwtools/api/driver.py
@@ -2,14 +2,6 @@
API access to the ``uwtools`` driver base classes.
"""
-from datetime import datetime, timedelta
-from importlib import import_module
-from importlib.util import module_from_spec, spec_from_file_location
-from inspect import getfullargspec
-from pathlib import Path
-from types import ModuleType
-from typing import Optional, Type, Union
-
from uwtools.drivers.driver import (
Assets,
AssetsCycleBased,
@@ -20,148 +12,6 @@
DriverCycleLeadtimeBased,
DriverTimeInvariant,
)
-from uwtools.drivers.support import graph
-from uwtools.drivers.support import tasks as _tasks
-from uwtools.logging import log
-from uwtools.strings import STR
-from uwtools.utils.api import ensure_data_source
-
-
-def execute(
- module: Union[Path, str],
- classname: str,
- task: str,
- schema_file: Optional[str] = None,
- config: Optional[Union[Path, str]] = None,
- cycle: Optional[datetime] = None, # pylint: disable=unused-argument
- leadtime: Optional[timedelta] = None, # pylint: disable=unused-argument
- batch: Optional[bool] = False, # pylint: disable=unused-argument
- dry_run: Optional[bool] = False,
- graph_file: Optional[Union[Path, str]] = None,
- key_path: Optional[list[str]] = None,
- stdin_ok: Optional[bool] = False,
-) -> bool:
- """
- Execute a task.
-
- If ``batch`` is specified, a runscript will be written and submitted to the batch system.
- Otherwise, the executable will be run directly on the current system.
-
- :param module: Path to driver module or name of module on sys.path.
- :param classname: Name of driver class to instantiate.
- :param task: Name of driver task to execute.
- :param schema_file: Path to schema file.
- :param config: Path to config file (read stdin if missing or None).
- :param cycle: The cycle.
- :param leadtime: The leadtime.
- :param batch: Submit run to the batch system?
- :param dry_run: Do not run the executable, just report what would have been done.
- :param graph_file: Write Graphviz DOT output here.
- :param key_path: Path of keys to subsection of config file.
- :param stdin_ok: OK to read from stdin?
- :return: ``True`` if task completes without raising an exception.
- """
- class_, module_path = _get_driver_class(module, classname)
- if not class_:
- return False
- assert module_path is not None
- args = dict(locals())
- accepted = set(getfullargspec(class_).args)
- non_optional = {STR.cycle, STR.leadtime}
- for arg in sorted([STR.batch, *non_optional]):
- if args.get(arg) and arg not in accepted:
- log.error("%s does not accept argument '%s'", classname, arg)
- return False
- for arg in sorted(non_optional):
- if arg in accepted and args[arg] is None:
- log.error("%s requires argument '%s'", classname, arg)
- return False
- kwargs = dict(
- config=ensure_data_source(config, bool(stdin_ok)),
- dry_run=dry_run,
- key_path=key_path,
- schema_file=schema_file or module_path.with_suffix(".jsonschema"),
- )
- required = non_optional & accepted
- for arg in sorted([STR.batch, *required]):
- if arg in accepted:
- kwargs[arg] = args[arg]
- driverobj = class_(**kwargs)
- log.debug("Instantiated %s with: %s", classname, kwargs)
- getattr(driverobj, task)()
- if graph_file:
- with open(graph_file, "w", encoding="utf-8") as f:
- print(graph(), file=f)
- return True
-
-
-def tasks(module: Union[Path, str], classname: str) -> dict[str, str]:
- """
- Returns a mapping from task names to their one-line descriptions.
-
- :param module: Name of driver module.
- :param classname: Name of driver class to instantiate.
- """
- class_, _ = _get_driver_class(module, classname)
- if not class_:
- log.error("Could not get tasks from class %s in module %s", classname, module)
- return {}
- return _tasks(class_)
-
-
-def _get_driver_class(
- module: Union[Path, str], classname: str
-) -> tuple[Optional[Type], Optional[Path]]:
- """
- Returns the driver class.
-
- :param module: Name of driver module to load.
- :param classname: Name of driver class to instantiate.
- """
- if not (m := _get_driver_module_explicit(Path(module))):
- if not (m := _get_driver_module_implicit(str(module))):
- log.error("Could not load module %s", module)
- return None, None
- assert m.__file__ is not None
- module_path = Path(m.__file__)
- if hasattr(m, classname):
- c: Type = getattr(m, classname)
- return c, module_path
- log.error("Module %s has no class %s", module, classname)
- return None, module_path
-
-
-def _get_driver_module_explicit(module: Path) -> Optional[ModuleType]:
- """
- Returns the named module found via explicit lookup of given path.
-
- :param module: Name of driver module to load.
- """
- log.debug("Loading module %s", module)
- if spec := spec_from_file_location(module.name, module):
- m = module_from_spec(spec)
- if loader := spec.loader:
- try:
- loader.exec_module(m)
- log.debug("Loaded module %s", module)
- return m
- except Exception: # pylint: disable=broad-exception-caught
- pass
- return None
-
-
-def _get_driver_module_implicit(module: str) -> Optional[ModuleType]:
- """
- Returns the named module found via implicit (sys.path-based) lookup.
-
- :param module: Name of driver module to load.
- """
- log.debug("Loading module %s from sys.path", module)
- try:
- return import_module(module)
- except Exception: # pylint: disable=broad-exception-caught
- return None
-
__all__ = [
"Assets",
@@ -172,7 +22,4 @@ def _get_driver_module_implicit(module: str) -> Optional[ModuleType]:
"DriverCycleBased",
"DriverCycleLeadtimeBased",
"DriverTimeInvariant",
- "execute",
- "graph",
- "tasks",
]
diff --git a/src/uwtools/api/esg_grid.py b/src/uwtools/api/esg_grid.py
index 06e3bac41..9bbd7e6f5 100644
--- a/src/uwtools/api/esg_grid.py
+++ b/src/uwtools/api/esg_grid.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``esg_grid`` driver.
"""
-from functools import partial
-
from uwtools.drivers.esg_grid import ESGGrid
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = ESGGrid
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["ESGGrid", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["ESGGrid", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/execute.py b/src/uwtools/api/execute.py
new file mode 100644
index 000000000..24c16cee6
--- /dev/null
+++ b/src/uwtools/api/execute.py
@@ -0,0 +1,157 @@
+"""
+API support for interacting with external drivers.
+"""
+
+from datetime import datetime, timedelta
+from importlib import import_module
+from importlib.util import module_from_spec, spec_from_file_location
+from inspect import getfullargspec
+from pathlib import Path
+from types import ModuleType
+from typing import Optional, Type, Union
+
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
+from uwtools.logging import log
+from uwtools.strings import STR
+from uwtools.utils.api import ensure_data_source
+
+
+def execute(
+ module: Union[Path, str],
+ classname: str,
+ task: str,
+ schema_file: Optional[str] = None,
+ config: Optional[Union[Path, str]] = None,
+ cycle: Optional[datetime] = None, # pylint: disable=unused-argument
+ leadtime: Optional[timedelta] = None, # pylint: disable=unused-argument
+ batch: Optional[bool] = False, # pylint: disable=unused-argument
+ dry_run: Optional[bool] = False,
+ graph_file: Optional[Union[Path, str]] = None,
+ key_path: Optional[list[str]] = None,
+ stdin_ok: Optional[bool] = False,
+) -> bool:
+ """
+ Execute a driver task.
+
+ If ``batch`` is specified and the driver is instructed to run, its runscript will be configured
+ for and submitted to the batch system. Otherwise, the executable will be run directly on the
+ current system.
+
+ :param module: Path to driver module or name of module on sys.path.
+ :param classname: Name of driver class to instantiate.
+ :param task: Name of driver task to execute.
+ :param schema_file: Path to schema file.
+ :param config: Path to config file (read stdin if missing or None).
+ :param cycle: The cycle.
+ :param leadtime: The leadtime.
+ :param batch: Submit run to the batch system?
+ :param dry_run: Do not run the executable, just report what would have been done.
+ :param graph_file: Write Graphviz DOT output here.
+ :param key_path: Path of keys to subsection of config file.
+ :param stdin_ok: OK to read from stdin?
+ :return: ``True`` if task completes without raising an exception.
+ """
+ class_, module_path = _get_driver_class(module, classname)
+ if not class_:
+ return False
+ assert module_path is not None
+ args = dict(locals())
+ accepted = set(getfullargspec(class_).args)
+ non_optional = {STR.cycle, STR.leadtime}
+ for arg in sorted([STR.batch, *non_optional]):
+ if args.get(arg) and arg not in accepted:
+ log.error("%s does not accept argument '%s'", classname, arg)
+ return False
+ for arg in sorted(non_optional):
+ if arg in accepted and args[arg] is None:
+ log.error("%s requires argument '%s'", classname, arg)
+ return False
+ kwargs = dict(
+ config=ensure_data_source(config, bool(stdin_ok)),
+ dry_run=dry_run,
+ key_path=key_path,
+ schema_file=schema_file or module_path.with_suffix(".jsonschema"),
+ )
+ required = non_optional & accepted
+ for arg in sorted([STR.batch, *required]):
+ if arg in accepted:
+ kwargs[arg] = args[arg]
+ driverobj = class_(**kwargs)
+ log.debug("Instantiated %s with: %s", classname, kwargs)
+ getattr(driverobj, task)()
+ if graph_file:
+ with open(graph_file, "w", encoding="utf-8") as f:
+ print(graph(), file=f)
+ return True
+
+
+def tasks(module: Union[Path, str], classname: str) -> dict[str, str]:
+ """
+ Return a mapping from driver task names to their one-line descriptions.
+
+ :param module: Name of driver module.
+ :param classname: Name of driver class to instantiate.
+ """
+ class_, _ = _get_driver_class(module, classname)
+ if not class_:
+ log.error("Could not get tasks from class %s in module %s", classname, module)
+ return {}
+ return _tasks(class_)
+
+
+def _get_driver_class(
+ module: Union[Path, str], classname: str
+) -> tuple[Optional[Type], Optional[Path]]:
+ """
+ Return the driver class.
+
+ :param module: Name of driver module to load.
+ :param classname: Name of driver class to instantiate.
+ """
+ if not (m := _get_driver_module_explicit(Path(module))):
+ if not (m := _get_driver_module_implicit(str(module))):
+ log.error("Could not load module %s", module)
+ return None, None
+ assert m.__file__ is not None
+ module_path = Path(m.__file__)
+ if hasattr(m, classname):
+ c: Type = getattr(m, classname)
+ return c, module_path
+ log.error("Module %s has no class %s", module, classname)
+ return None, module_path
+
+
+def _get_driver_module_explicit(module: Path) -> Optional[ModuleType]:
+ """
+ Return the named module found via explicit lookup of given path.
+
+ :param module: Name of driver module to load.
+ """
+ log.debug("Loading module %s", module)
+ if spec := spec_from_file_location(module.name, module):
+ m = module_from_spec(spec)
+ if loader := spec.loader:
+ try:
+ loader.exec_module(m)
+ log.debug("Loaded module %s", module)
+ return m
+ except Exception: # pylint: disable=broad-exception-caught
+ pass
+ return None
+
+
+def _get_driver_module_implicit(module: str) -> Optional[ModuleType]:
+ """
+ Return the named module found via implicit (sys.path-based) lookup.
+
+ :param module: Name of driver module to load.
+ """
+ log.debug("Loading module %s from sys.path", module)
+ try:
+ return import_module(module)
+ except Exception: # pylint: disable=broad-exception-caught
+ return None
+
+
+__all__ = ["execute", "graph", "tasks"]
diff --git a/src/uwtools/api/filter_topo.py b/src/uwtools/api/filter_topo.py
index fac93ecf6..ded7d6cd6 100644
--- a/src/uwtools/api/filter_topo.py
+++ b/src/uwtools/api/filter_topo.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``filter_topo`` driver.
"""
-from functools import partial
-
from uwtools.drivers.filter_topo import FilterTopo
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = FilterTopo
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["FilterTopo", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["FilterTopo", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/file.py b/src/uwtools/api/fs.py
similarity index 61%
rename from src/uwtools/api/file.py
rename to src/uwtools/api/fs.py
index 7bac2ddef..5b7d6102f 100644
--- a/src/uwtools/api/file.py
+++ b/src/uwtools/api/fs.py
@@ -1,5 +1,5 @@
"""
-API access to ``uwtools`` file-management tools.
+API access to ``uwtools`` file and directory management tools.
"""
import datetime as dt
@@ -8,7 +8,7 @@
from iotaa import Asset
-from uwtools.file import Copier, Linker
+from uwtools.fs import Copier, Linker, MakeDirs
from uwtools.utils.api import ensure_data_source as _ensure_data_source
@@ -33,7 +33,7 @@ def copy(
:param stdin_ok: OK to read from ``stdin``?
:return: ``True`` if all copies were created.
"""
- copier = Copier(
+ stager = Copier(
target_dir=Path(target_dir) if target_dir else None,
config=_ensure_data_source(config, stdin_ok),
cycle=cycle,
@@ -41,7 +41,7 @@ def copy(
keys=keys,
dry_run=dry_run,
)
- assets: list[Asset] = copier.go() # type: ignore
+ assets: list[Asset] = stager.go() # type: ignore
return all(asset.ready() for asset in assets)
@@ -66,7 +66,7 @@ def link(
:param stdin_ok: OK to read from ``stdin``?
:return: ``True`` if all links were created.
"""
- linker = Linker(
+ stager = Linker(
target_dir=Path(target_dir) if target_dir else None,
config=_ensure_data_source(config, stdin_ok),
cycle=cycle,
@@ -74,8 +74,41 @@ def link(
keys=keys,
dry_run=dry_run,
)
- assets: list[Asset] = linker.go() # type: ignore
+ assets: list[Asset] = stager.go() # type: ignore
return all(asset.ready() for asset in assets)
-__all__ = ["Copier", "Linker", "copy", "link"]
+def makedirs(
+ config: Optional[Union[Path, dict, str]] = None,
+ target_dir: Optional[Union[Path, str]] = None,
+ cycle: Optional[dt.datetime] = None,
+ leadtime: Optional[dt.timedelta] = None,
+ keys: Optional[list[str]] = None,
+ dry_run: bool = False,
+ stdin_ok: bool = False,
+) -> bool:
+ """
+ Make directories.
+
+ :param config: YAML-file path, or ``dict`` (read ``stdin`` if missing or ``None``).
+ :param target_dir: Path to target directory.
+ :param cycle: A datetime object to make available for use in the config.
+ :param leadtime: A timedelta object to make available for use in the config.
+ :param keys: YAML keys leading to file dst/src block.
+ :param dry_run: Do not link files.
+ :param stdin_ok: OK to read from ``stdin``?
+ :return: ``True`` if all directories were made.
+ """
+ stager = MakeDirs(
+ target_dir=Path(target_dir) if target_dir else None,
+ config=_ensure_data_source(config, stdin_ok),
+ cycle=cycle,
+ leadtime=leadtime,
+ keys=keys,
+ dry_run=dry_run,
+ )
+ assets: list[Asset] = stager.go() # type: ignore
+ return all(asset.ready() for asset in assets)
+
+
+__all__ = ["Copier", "Linker", "MakeDirs", "copy", "link", "makedirs"]
diff --git a/src/uwtools/api/fv3.py b/src/uwtools/api/fv3.py
index 4c613b2dd..b5a9401b0 100644
--- a/src/uwtools/api/fv3.py
+++ b/src/uwtools/api/fv3.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``fv3`` driver.
"""
-from functools import partial
-
from uwtools.drivers.fv3 import FV3
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = FV3
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["FV3", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["FV3", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/global_equiv_resol.py b/src/uwtools/api/global_equiv_resol.py
index 6bd3940b5..6a6484354 100644
--- a/src/uwtools/api/global_equiv_resol.py
+++ b/src/uwtools/api/global_equiv_resol.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``global_equiv_resol`` driver.
"""
-from functools import partial
-
from uwtools.drivers.global_equiv_resol import GlobalEquivResol
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = GlobalEquivResol
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["GlobalEquivResol", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["GlobalEquivResol", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/ioda.py b/src/uwtools/api/ioda.py
index 5abe332ca..cea6e5fa1 100644
--- a/src/uwtools/api/ioda.py
+++ b/src/uwtools/api/ioda.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``ioda`` driver.
"""
-from functools import partial
-
from uwtools.drivers.ioda import IODA
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = IODA
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["IODA", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["IODA", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/jedi.py b/src/uwtools/api/jedi.py
index c0986c277..1cb0f1ff1 100644
--- a/src/uwtools/api/jedi.py
+++ b/src/uwtools/api/jedi.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``jedi`` driver.
"""
-from functools import partial
-
from uwtools.drivers.jedi import JEDI
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = JEDI
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["JEDI", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["JEDI", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/make_hgrid.py b/src/uwtools/api/make_hgrid.py
index 63886ce03..68de11ff4 100644
--- a/src/uwtools/api/make_hgrid.py
+++ b/src/uwtools/api/make_hgrid.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``make_hgrid`` driver.
"""
-from functools import partial
-
from uwtools.drivers.make_hgrid import MakeHgrid
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = MakeHgrid
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["MakeHgrid", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["MakeHgrid", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/make_solo_mosaic.py b/src/uwtools/api/make_solo_mosaic.py
index d9dc4ebe7..c6ddb6bc9 100644
--- a/src/uwtools/api/make_solo_mosaic.py
+++ b/src/uwtools/api/make_solo_mosaic.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``make_solo_mosaic`` driver.
"""
-from functools import partial
-
from uwtools.drivers.make_solo_mosaic import MakeSoloMosaic
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = MakeSoloMosaic
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["MakeSoloMosaic", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["MakeSoloMosaic", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/mpas.py b/src/uwtools/api/mpas.py
index 21ccb9cdb..3ce1245ad 100644
--- a/src/uwtools/api/mpas.py
+++ b/src/uwtools/api/mpas.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``mpas`` driver.
"""
-from functools import partial
-
from uwtools.drivers.mpas import MPAS
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = MPAS
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["MPAS", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["MPAS", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/mpas_init.py b/src/uwtools/api/mpas_init.py
index 2654af782..2308a578f 100644
--- a/src/uwtools/api/mpas_init.py
+++ b/src/uwtools/api/mpas_init.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``mpas_init`` driver.
"""
-from functools import partial
-
from uwtools.drivers.mpas_init import MPASInit
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = MPASInit
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["MPASInit", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["MPASInit", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/orog_gsl.py b/src/uwtools/api/orog_gsl.py
index ff6973866..f56c7f67e 100644
--- a/src/uwtools/api/orog_gsl.py
+++ b/src/uwtools/api/orog_gsl.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``orog_gsl`` driver.
"""
-from functools import partial
-
from uwtools.drivers.orog_gsl import OrogGSL
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = OrogGSL
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["OrogGSL", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["OrogGSL", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/schism.py b/src/uwtools/api/schism.py
index 50cfe73f5..50e4e3ffb 100644
--- a/src/uwtools/api/schism.py
+++ b/src/uwtools/api/schism.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``schism`` driver.
"""
-from functools import partial
-
from uwtools.drivers.schism import SCHISM
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = SCHISM
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["SCHISM", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["SCHISM", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/sfc_climo_gen.py b/src/uwtools/api/sfc_climo_gen.py
index 7e881def3..d882d6357 100644
--- a/src/uwtools/api/sfc_climo_gen.py
+++ b/src/uwtools/api/sfc_climo_gen.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``sfc_climo_gen`` driver.
"""
-from functools import partial
-
from uwtools.drivers.sfc_climo_gen import SfcClimoGen
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = SfcClimoGen
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["SfcClimoGen", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["SfcClimoGen", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/shave.py b/src/uwtools/api/shave.py
index ff3b9911f..0dd01ffd8 100644
--- a/src/uwtools/api/shave.py
+++ b/src/uwtools/api/shave.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``shave`` driver.
"""
-from functools import partial
-
from uwtools.drivers.shave import Shave
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.utils.api import make_execute as _make_execute
_driver = Shave
execute = _make_execute(_driver)
-tasks = partial(tasks, _driver)
-__all__ = ["Shave", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["Shave", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/ungrib.py b/src/uwtools/api/ungrib.py
index 1bfa0c7fa..34a353dbd 100644
--- a/src/uwtools/api/ungrib.py
+++ b/src/uwtools/api/ungrib.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``ungrib`` driver.
"""
-from functools import partial
-
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.drivers.ungrib import Ungrib
from uwtools.utils.api import make_execute as _make_execute
_driver = Ungrib
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["Ungrib", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["Ungrib", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/upp.py b/src/uwtools/api/upp.py
index 9a098df42..0def005f9 100644
--- a/src/uwtools/api/upp.py
+++ b/src/uwtools/api/upp.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``upp`` driver.
"""
-from functools import partial
-
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.drivers.upp import UPP
from uwtools.utils.api import make_execute as _make_execute
_driver = UPP
execute = _make_execute(_driver, with_cycle=True, with_leadtime=True)
-tasks = partial(tasks, _driver)
-__all__ = ["UPP", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["UPP", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/api/ww3.py b/src/uwtools/api/ww3.py
index dd1a4759f..25fe70e32 100644
--- a/src/uwtools/api/ww3.py
+++ b/src/uwtools/api/ww3.py
@@ -2,13 +2,27 @@
API access to the ``uwtools`` ``ww3`` driver.
"""
-from functools import partial
-
-from uwtools.drivers.support import graph, tasks
+from uwtools.drivers.support import graph
+from uwtools.drivers.support import tasks as _tasks
from uwtools.drivers.ww3 import WaveWatchIII
from uwtools.utils.api import make_execute as _make_execute
_driver = WaveWatchIII
execute = _make_execute(_driver, with_cycle=True)
-tasks = partial(tasks, _driver)
-__all__ = ["WaveWatchIII", "execute", "graph", "tasks"]
+
+
+def schema() -> dict:
+ """
+ Return the driver's schema.
+ """
+ return _driver.schema()
+
+
+def tasks() -> dict[str, str]:
+ """
+ Return a mapping from task names to their one-line descriptions.
+ """
+ return _tasks(_driver)
+
+
+__all__ = ["WaveWatchIII", "execute", "graph", "schema", "tasks"]
diff --git a/src/uwtools/cli.py b/src/uwtools/cli.py
index ce1941e8d..15f3dda36 100644
--- a/src/uwtools/cli.py
+++ b/src/uwtools/cli.py
@@ -18,7 +18,8 @@
import uwtools.api
import uwtools.api.config
import uwtools.api.driver
-import uwtools.api.file
+import uwtools.api.execute
+import uwtools.api.fs
import uwtools.api.rocoto
import uwtools.api.template
import uwtools.config.jinja2
@@ -51,9 +52,9 @@ def main() -> None:
setup_logging(quiet=True)
args, checks = _parse_args(sys.argv[1:])
args[STR.action] = args.get(STR.action, args[STR.mode])
- for check in checks[args[STR.mode]][args[STR.action]]:
+ for check in checks[args[STR.mode]].get(args[STR.action], []):
check(args)
- setup_logging(quiet=args[STR.quiet], verbose=args[STR.verbose])
+ setup_logging(quiet=args.get(STR.quiet, False), verbose=args.get(STR.verbose, False))
except UWError as e:
_abort(str(e))
try:
@@ -61,7 +62,7 @@ def main() -> None:
tools: dict[str, Callable[..., bool]] = {
STR.config: _dispatch_config,
STR.execute: _dispatch_execute,
- STR.file: _dispatch_file,
+ STR.fs: _dispatch_fs,
STR.rocoto: _dispatch_rocoto,
STR.template: _dispatch_template,
}
@@ -99,7 +100,7 @@ def main() -> None:
def _add_subparser_config(subparsers: Subparsers) -> ModeChecks:
"""
- Subparser for mode: config
+ Add subparser for mode: config
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -115,7 +116,7 @@ def _add_subparser_config(subparsers: Subparsers) -> ModeChecks:
def _add_subparser_config_compare(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: config compare
+ Add subparser for mode: config compare
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -145,7 +146,7 @@ def _add_subparser_config_compare(subparsers: Subparsers) -> ActionChecks:
def _add_subparser_config_realize(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: config realize
+ Add subparser for mode: config realize
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -171,7 +172,7 @@ def _add_subparser_config_realize(subparsers: Subparsers) -> ActionChecks:
def _add_subparser_config_validate(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: config validate
+ Add subparser for mode: config validate
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -185,7 +186,7 @@ def _add_subparser_config_validate(subparsers: Subparsers) -> ActionChecks:
def _dispatch_config(args: Args) -> bool:
"""
- Dispatch logic for config mode.
+ Define dispatch logic for config mode.
:param args: Parsed command-line args.
"""
@@ -199,7 +200,7 @@ def _dispatch_config(args: Args) -> bool:
def _dispatch_config_compare(args: Args) -> bool:
"""
- Dispatch logic for config compare action.
+ Define dispatch logic for config compare action.
:param args: Parsed command-line args.
"""
@@ -213,7 +214,7 @@ def _dispatch_config_compare(args: Args) -> bool:
def _dispatch_config_realize(args: Args) -> bool:
"""
- Dispatch logic for config realize action.
+ Define dispatch logic for config realize action.
:param args: Parsed command-line args.
"""
@@ -241,7 +242,7 @@ def _dispatch_config_realize(args: Args) -> bool:
def _dispatch_config_validate(args: Args) -> bool:
"""
- Dispatch logic for config validate action.
+ Define dispatch logic for config validate action.
:param args: Parsed command-line args.
"""
@@ -257,7 +258,7 @@ def _dispatch_config_validate(args: Args) -> bool:
def _add_subparser_execute(subparsers: Subparsers) -> ModeChecks:
"""
- Subparser for mode: execute
+ Add subparser for mode: execute
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -284,11 +285,11 @@ def _add_subparser_execute(subparsers: Subparsers) -> ModeChecks:
def _dispatch_execute(args: Args) -> bool:
"""
- Dispatch logic for execute mode.
+ Define dispatch logic for execute mode.
:param args: Parsed command-line args.
"""
- return uwtools.api.driver.execute(
+ return uwtools.api.execute.execute(
classname=args[STR.classname],
module=args[STR.module],
task=args[STR.task],
@@ -304,27 +305,28 @@ def _dispatch_execute(args: Args) -> bool:
)
-# Mode file
+# Mode fs
-def _add_subparser_file(subparsers: Subparsers) -> ModeChecks:
+def _add_subparser_fs(subparsers: Subparsers) -> ModeChecks:
"""
- Subparser for mode: file
+ Add subparser for mode: fs
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
- parser = _add_subparser(subparsers, STR.file, "Handle files")
+ parser = _add_subparser(subparsers, STR.fs, "Handle filesystem items (files and directories)")
_basic_setup(parser)
subparsers = _add_subparsers(parser, STR.action, STR.action.upper())
return {
- STR.copy: _add_subparser_file_copy(subparsers),
- STR.link: _add_subparser_file_link(subparsers),
+ STR.copy: _add_subparser_fs_copy(subparsers),
+ STR.link: _add_subparser_fs_link(subparsers),
+ STR.makedirs: _add_subparser_fs_makedirs(subparsers),
}
-def _add_subparser_file_common(parser: Parser) -> ActionChecks:
+def _add_subparser_fs_common(parser: Parser) -> ActionChecks:
"""
- Common subparser code for mode: file {copy link}
+ Perform common subparser setup for mode: fs {copy link makedirs}
:param parser: The parser to configure.
"""
@@ -339,46 +341,57 @@ def _add_subparser_file_common(parser: Parser) -> ActionChecks:
return checks
-def _add_subparser_file_copy(subparsers: Subparsers) -> ActionChecks:
+def _add_subparser_fs_copy(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: file copy
+ Add subparser for mode: fs copy
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
parser = _add_subparser(subparsers, STR.copy, "Copy files")
- return _add_subparser_file_common(parser)
+ return _add_subparser_fs_common(parser)
-def _add_subparser_file_link(subparsers: Subparsers) -> ActionChecks:
+def _add_subparser_fs_link(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: file link
+ Add subparser for mode: fs link
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
parser = _add_subparser(subparsers, STR.link, "Link files")
- return _add_subparser_file_common(parser)
+ return _add_subparser_fs_common(parser)
-def _dispatch_file(args: Args) -> bool:
+def _add_subparser_fs_makedirs(subparsers: Subparsers) -> ActionChecks:
"""
- Dispatch logic for file mode.
+ Add subparser for mode: fs makedirs
+
+ :param subparsers: Parent parser's subparsers, to add this subparser to.
+ """
+ parser = _add_subparser(subparsers, STR.makedirs, "Make directories")
+ return _add_subparser_fs_common(parser)
+
+
+def _dispatch_fs(args: Args) -> bool:
+ """
+ Define dispatch logic for fs mode.
:param args: Parsed command-line args.
"""
actions = {
- STR.copy: _dispatch_file_copy,
- STR.link: _dispatch_file_link,
+ STR.copy: _dispatch_fs_copy,
+ STR.link: _dispatch_fs_link,
+ STR.makedirs: _dispatch_fs_makedirs,
}
return actions[args[STR.action]](args)
-def _dispatch_file_copy(args: Args) -> bool:
+def _dispatch_fs_copy(args: Args) -> bool:
"""
- Dispatch logic for file copy action.
+ Define dispatch logic for fs copy action.
:param args: Parsed command-line args.
"""
- return uwtools.api.file.copy(
+ return uwtools.api.fs.copy(
target_dir=args[STR.targetdir],
config=args[STR.cfgfile],
cycle=args[STR.cycle],
@@ -389,13 +402,30 @@ def _dispatch_file_copy(args: Args) -> bool:
)
-def _dispatch_file_link(args: Args) -> bool:
+def _dispatch_fs_link(args: Args) -> bool:
"""
- Dispatch logic for file link action.
+ Define dispatch logic for fs link action.
:param args: Parsed command-line args.
"""
- return uwtools.api.file.link(
+ return uwtools.api.fs.link(
+ target_dir=args[STR.targetdir],
+ config=args[STR.cfgfile],
+ cycle=args[STR.cycle],
+ leadtime=args[STR.leadtime],
+ keys=args[STR.keys],
+ dry_run=args[STR.dryrun],
+ stdin_ok=True,
+ )
+
+
+def _dispatch_fs_makedirs(args: Args) -> bool:
+ """
+ Define dispatch logic for fs makedirs action.
+
+ :param args: Parsed command-line args.
+ """
+ return uwtools.api.fs.makedirs(
target_dir=args[STR.targetdir],
config=args[STR.cfgfile],
cycle=args[STR.cycle],
@@ -411,7 +441,7 @@ def _dispatch_file_link(args: Args) -> bool:
def _add_subparser_rocoto(subparsers: Subparsers) -> ModeChecks:
"""
- Subparser for mode: rocoto
+ Add subparser for mode: rocoto
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -426,7 +456,7 @@ def _add_subparser_rocoto(subparsers: Subparsers) -> ModeChecks:
def _add_subparser_rocoto_realize(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: rocoto realize
+ Add subparser for mode: rocoto realize
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -440,7 +470,7 @@ def _add_subparser_rocoto_realize(subparsers: Subparsers) -> ActionChecks:
def _add_subparser_rocoto_validate(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: rocoto validate
+ Add subparser for mode: rocoto validate
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -453,7 +483,7 @@ def _add_subparser_rocoto_validate(subparsers: Subparsers) -> ActionChecks:
def _dispatch_rocoto(args: Args) -> bool:
"""
- Dispatch logic for rocoto mode.
+ Define dispatch logic for rocoto mode.
:param args: Parsed command-line args.
"""
@@ -466,7 +496,7 @@ def _dispatch_rocoto(args: Args) -> bool:
def _dispatch_rocoto_realize(args: Args) -> bool:
"""
- Dispatch logic for rocoto realize action. Validate input and output.
+ Define dispatch logic for rocoto realize action. Validate input and output.
:param args: Parsed command-line args.
"""
@@ -479,7 +509,7 @@ def _dispatch_rocoto_realize(args: Args) -> bool:
def _dispatch_rocoto_validate(args: Args) -> bool:
"""
- Dispatch logic for rocoto validate action.
+ Define dispatch logic for rocoto validate action.
:param args: Parsed command-line args.
"""
@@ -491,7 +521,7 @@ def _dispatch_rocoto_validate(args: Args) -> bool:
def _add_subparser_template(subparsers: Subparsers) -> ModeChecks:
"""
- Subparser for mode: template
+ Add subparser for mode: template
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -506,7 +536,7 @@ def _add_subparser_template(subparsers: Subparsers) -> ModeChecks:
def _add_subparser_template_translate(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: template translate
+ Add subparser for mode: template translate
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -520,7 +550,7 @@ def _add_subparser_template_translate(subparsers: Subparsers) -> ActionChecks:
def _add_subparser_template_render(subparsers: Subparsers) -> ActionChecks:
"""
- Subparser for mode: template render
+ Add subparser for mode: template render
:param subparsers: Parent parser's subparsers, to add this subparser to.
"""
@@ -541,7 +571,7 @@ def _add_subparser_template_render(subparsers: Subparsers) -> ActionChecks:
def _dispatch_template(args: Args) -> bool:
"""
- Dispatch logic for template mode.
+ Define dispatch logic for template mode.
:param args: Parsed command-line args.
"""
@@ -554,7 +584,7 @@ def _dispatch_template(args: Args) -> bool:
def _dispatch_template_render(args: Args) -> bool:
"""
- Dispatch logic for template render action.
+ Define dispatch logic for template render action.
:param args: Parsed command-line args.
"""
@@ -581,7 +611,7 @@ def _dispatch_template_render(args: Args) -> bool:
def _dispatch_template_translate(args: Args) -> bool:
"""
- Dispatch logic for template translate action.
+ Define dispatch logic for template translate action.
:param args: Parsed command-line args.
"""
@@ -800,6 +830,14 @@ def _add_arg_search_path(group: Group) -> None:
)
+def _add_arg_show_schema(group: Group) -> None:
+ group.add_argument(
+ _switch(STR.showschema),
+ action="store_true",
+ help="Show driver schema and exit",
+ )
+
+
def _add_arg_target_dir(
group: Group, required: bool = False, helpmsg: Optional[str] = None
) -> None:
@@ -938,7 +976,7 @@ def _add_subparser_for_driver(
with_leadtime: Optional[bool] = False,
) -> ModeChecks:
"""
- Subparser for a standalone-driver mode.
+ Add subparser for a standalone-driver mode.
:param name: Name of the driver whose subparser to configure.
:param subparsers: Parent parser's subparsers, to add this subparser to.
@@ -947,8 +985,9 @@ def _add_subparser_for_driver(
:param with_leadtime: Does this driver require a leadtime?
"""
parser = _add_subparser(subparsers, name, "Execute %s tasks" % name)
- _basic_setup(parser)
- subparsers = _add_subparsers(parser, STR.action, STR.task.upper())
+ optional = _basic_setup(parser)
+ _add_arg_show_schema(optional)
+ subparsers = _add_subparsers(parser, STR.action, STR.task.upper(), required=False)
return {
task: _add_subparser_for_driver_task(
subparsers, task, helpmsg, with_batch, with_cycle, with_leadtime
@@ -966,7 +1005,7 @@ def _add_subparser_for_driver_task(
with_leadtime: Optional[bool] = False,
) -> ActionChecks:
"""
- Subparser for a driver action.
+ Add subparser for a driver action.
:param subparsers: Parent parser's subparsers, to add this subparser to.
:param task: The task to add a subparser for.
@@ -996,7 +1035,7 @@ def _add_subparser_for_driver_task(
return checks
-def _add_subparsers(parser: Parser, dest: str, metavar: str) -> Subparsers:
+def _add_subparsers(parser: Parser, dest: str, metavar: str, required: bool = True) -> Subparsers:
"""
Add subparsers to a parser.
@@ -1006,7 +1045,7 @@ def _add_subparsers(parser: Parser, dest: str, metavar: str) -> Subparsers:
:return: The new subparsers object.
"""
return parser.add_subparsers(
- dest=dest, metavar=metavar, required=True, title="Positional arguments"
+ dest=dest, metavar=metavar, required=required, title="Positional arguments"
)
@@ -1071,12 +1110,18 @@ def _dict_from_key_eq_val_strings(config_items: list[str]) -> dict[str, str]:
def _dispatch_to_driver(name: str, args: Args) -> bool:
"""
- Dispatch logic for a driver mode.
+ Define dispatch logic for a driver mode.
:param name: Name of the driver to dispatch to.
:param args: Parsed command-line args.
"""
- execute: Callable[..., bool] = import_module("uwtools.api.%s" % name).execute
+ module = import_module("uwtools.api.%s" % name)
+ if args.get(STR.showschema):
+ print(json.dumps(module.schema(), sort_keys=True, indent=2))
+ return True
+ if not args.get(STR.action):
+ _abort("No %s specified" % STR.task.upper())
+ execute: Callable[..., bool] = module.execute
kwargs = {
"task": args[STR.action],
"config": args[STR.cfgfile],
@@ -1093,7 +1138,7 @@ def _dispatch_to_driver(name: str, args: Args) -> bool:
def _formatter(prog: str) -> HelpFormatter:
"""
- A standard formatter for help messages.
+ Return a standard formatter for help messages.
"""
# max_help_positions sets the maximum starting column for option help text.
return HelpFormatter(prog, max_help_position=6)
@@ -1114,7 +1159,7 @@ def _parse_args(raw_args: list[str]) -> tuple[Args, Checks]:
tools = {
STR.config: partial(_add_subparser_config, subparsers),
STR.execute: partial(_add_subparser_execute, subparsers),
- STR.file: partial(_add_subparser_file, subparsers),
+ STR.fs: partial(_add_subparser_fs, subparsers),
STR.rocoto: partial(_add_subparser_rocoto, subparsers),
STR.template: partial(_add_subparser_template, subparsers),
}
diff --git a/src/uwtools/config/atparse_to_jinja2.py b/src/uwtools/config/atparse_to_jinja2.py
index 049ee987d..22a387913 100644
--- a/src/uwtools/config/atparse_to_jinja2.py
+++ b/src/uwtools/config/atparse_to_jinja2.py
@@ -14,7 +14,7 @@ def convert(
input_file: Optional[Path] = None, output_file: Optional[Path] = None, dry_run: bool = False
) -> None:
"""
- Replaces atparse @[] tokens with Jinja2 {{}} equivalents.
+ Replace atparse @[] tokens with Jinja2 {{}} equivalents.
If no input file is given, stdin is used. If no output file is given, stdout is used. In dry-run
mode, output is written to stderr.
diff --git a/src/uwtools/config/formats/base.py b/src/uwtools/config/formats/base.py
index 854449e11..c93209f07 100644
--- a/src/uwtools/config/formats/base.py
+++ b/src/uwtools/config/formats/base.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
import os
import re
from abc import ABC, abstractmethod
@@ -25,8 +23,6 @@ class Config(ABC, UserDict):
def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None:
"""
- Construct a Config object.
-
:param config: Config file to load (None => read from stdin), or initial dict.
"""
super().__init__()
@@ -36,35 +32,86 @@ def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None:
else:
self._config_file = str2path(config) if config else None
self.data = self._load(self._config_file)
- if self.get_depth_threshold() and self.depth != self.get_depth_threshold():
+ if self._get_depth_threshold() and self._depth != self._get_depth_threshold():
raise UWConfigError(
"Cannot instantiate depth-%s %s with depth-%s config"
- % (self.get_depth_threshold(), type(self).__name__, self.depth)
+ % (self._get_depth_threshold(), type(self).__name__, self._depth)
)
def __repr__(self) -> str:
"""
- Returns the string representation of a Config object.
+ Return the string representation of a Config object.
"""
return self._dict_to_str(self.data)
# Private methods
+ def _characterize_values(self, values: dict, parent: str) -> tuple[list, list]:
+ """
+ Characterize values as complete or as template placeholders.
+
+ :param values: The dictionary to examine.
+ :param parent: Parent key.
+ :return: Lists of of complete and template-placeholder values.
+ """
+ complete: list[str] = []
+ template: list[str] = []
+ for key, val in values.items():
+ if isinstance(val, dict):
+ complete.append(f"{INDENT}{parent}{key}")
+ c, t = self._characterize_values(val, f"{parent}{key}.")
+ complete, template = complete + c, template + t
+ elif isinstance(val, list):
+ for item in val:
+ if isinstance(item, dict):
+ c, t = self._characterize_values(item, parent)
+ complete, template = complete + c, template + t
+ complete.append(f"{INDENT}{parent}{key}")
+ elif "{{" in str(val) or "{%" in str(val):
+ template.append(f"{INDENT}{parent}{key}: {val}")
+ break
+ elif "{{" in str(val) or "{%" in str(val):
+ template.append(f"{INDENT}{parent}{key}: {val}")
+ else:
+ complete.append(f"{INDENT}{parent}{key}")
+ return complete, template
+
+ @property
+ def _depth(self) -> int:
+ """
+ The depth of this config's hierarchy.
+ """
+ return depth(self.data)
+
@classmethod
@abstractmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the string representation of the given dict.
+ Return the string representation of the given dict.
:param cfg: A dict object.
"""
+ @staticmethod
+ @abstractmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+
+ @staticmethod
+ @abstractmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+
@abstractmethod
def _load(self, config_file: Optional[Path]) -> dict:
"""
- Reads and parses a config file.
+ Read and parse a config file.
- Returns the result of loading and parsing the specified config file, or stdin if no file is
+ Return the result of loading and parsing the specified config file, or stdin if no file is
given.
:param config_file: Path to config file to load.
@@ -88,37 +135,28 @@ def _load_paths(self, config_files: list[Path]) -> dict:
cfg.update(self._load(config_file=config_file))
return cfg
- # Public methods
-
- def characterize_values(self, values: dict, parent: str) -> tuple[list, list]:
+ def _parse_include(self, ref_dict: Optional[dict] = None) -> None:
"""
- Characterize values as complete or as template placeholders.
+ Recursively process include directives in a config object.
- :param values: The dictionary to examine.
- :param parent: Parent key.
- :return: Lists of of complete and template-placeholder values.
+ Recursively traverse the dictionary, replacing include tags with the contents of the files
+ they specify. Assumes a section/key/value structure. YAML provides this functionality in its
+ own loader.
+
+ :param ref_dict: A config object to process instead of the object's own data.
"""
- complete: list[str] = []
- template: list[str] = []
- for key, val in values.items():
- if isinstance(val, dict):
- complete.append(f"{INDENT}{parent}{key}")
- c, t = self.characterize_values(val, f"{parent}{key}.")
- complete, template = complete + c, template + t
- elif isinstance(val, list):
- for item in val:
- if isinstance(item, dict):
- c, t = self.characterize_values(item, parent)
- complete, template = complete + c, template + t
- complete.append(f"{INDENT}{parent}{key}")
- elif "{{" in str(val) or "{%" in str(val):
- template.append(f"{INDENT}{parent}{key}: {val}")
- break
- elif "{{" in str(val) or "{%" in str(val):
- template.append(f"{INDENT}{parent}{key}: {val}")
- else:
- complete.append(f"{INDENT}{parent}{key}")
- return complete, template
+ if ref_dict is None:
+ ref_dict = self.data
+ for key, value in deepcopy(ref_dict).items():
+ if isinstance(value, dict):
+ self._parse_include(ref_dict[key])
+ elif isinstance(value, str):
+ if m := re.match(r"^\s*%s\s+(.*)" % INCLUDE_TAG, value):
+ filepaths = yaml.safe_load(m[1])
+ self.update_from(self._load_paths(filepaths))
+ del ref_dict[key]
+
+ # Public methods
def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:
"""
@@ -127,7 +165,7 @@ def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:
Assumes a section/key/value structure.
:param dict1: The first dictionary.
- :param dict2: The second dictionary.
+ :param dict2: The second dictionary (default: this config).
:return: True if the configs are identical, False otherwise.
"""
dict2 = self.data if dict2 is None else dict2
@@ -158,13 +196,6 @@ def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:
return not diffs
- @property
- def depth(self) -> int:
- """
- Returns the depth of this config's hierarchy.
- """
- return depth(self.data)
-
def dereference(self, context: Optional[dict] = None) -> None:
"""
Render as much Jinja2 syntax as possible.
@@ -187,59 +218,24 @@ def logstate(state: str) -> None:
@abstractmethod
def dump(self, path: Optional[Path]) -> None:
"""
- Dumps the config to stdout or a file.
-
- :param path: Path to dump config to.
- """
+ Dump the config to stdout or a file.
- @staticmethod
- @abstractmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
+ :param path: Path to dump config to (default: stdout).
"""
@staticmethod
@abstractmethod
def dump_dict(cfg: dict, path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary to stdout or a file.
+ Dump a provided config dictionary to stdout or a file.
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
- @staticmethod
- @abstractmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
-
- def parse_include(self, ref_dict: Optional[dict] = None) -> None:
- """
- Recursively process include directives in a config object.
-
- Recursively traverse the dictionary, replacing include tags with the contents of the files
- they specify. Assumes a section/key/value structure. YAML provides this functionality in its
- own loader.
-
- :param ref_dict: A config object to process instead of the object's own data.
- """
- if ref_dict is None:
- ref_dict = self.data
- for key, value in deepcopy(ref_dict).items():
- if isinstance(value, dict):
- self.parse_include(ref_dict[key])
- elif isinstance(value, str):
- if m := re.match(r"^\s*%s\s+(.*)" % INCLUDE_TAG, value):
- filepaths = yaml.safe_load(m[1])
- self.update_values(self._load_paths(filepaths))
- del ref_dict[key]
-
- def update_values(self, src: Union[dict, Config]) -> None:
+ def update_from(self, src: Union[dict, UserDict]) -> None:
"""
- Updates a config.
+ Update a config.
:param src: The dictionary with new data to use.
"""
@@ -254,4 +250,4 @@ def update(src: dict, dst: dict) -> None:
else:
dst[key] = val
- update(src.data if isinstance(src, Config) else src, self.data)
+ update(src.data if isinstance(src, UserDict) else src, self.data)
diff --git a/src/uwtools/config/formats/fieldtable.py b/src/uwtools/config/formats/fieldtable.py
index 45705e77f..3e1ffd184 100644
--- a/src/uwtools/config/formats/fieldtable.py
+++ b/src/uwtools/config/formats/fieldtable.py
@@ -8,8 +8,7 @@
class FieldTableConfig(YAMLConfig):
"""
- This class exists to write out a field_table format given that its configuration has been set by
- an input YAML file.
+ Work with configs in field_table format.
"""
# Private methods
@@ -17,7 +16,7 @@ class FieldTableConfig(YAMLConfig):
@classmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the field-table representation of the given dict.
+ Return the field-table representation of the given dict.
:param cfg: A dict object.
"""
@@ -37,22 +36,36 @@ def _dict_to_str(cls, cfg: dict) -> str:
lines[-1] += " /"
return "\n".join(lines)
+ @staticmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+ return None
+
+ @staticmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+ return FORMAT.fieldtable
+
# Public methods
def dump(self, path: Optional[Path] = None) -> None:
"""
- Dumps the config in Field Table format.
+ Dump the config in Field Table format.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
self.dump_dict(self.data, path)
@classmethod
def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary in Field Table format.
+ Dump a provided config dictionary in Field Table format.
- FMS field and tracer managers must be registered in an ASCII table called 'field_table'.
+ FMS field and tracer managers must be registered in an ASCII table called ``field_table``.
This table lists field type, target model and methods the querying model will ask for. See
UFS documentation for more information:
@@ -60,29 +73,17 @@ def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None:
The example format for generating a field file is:
- sphum:
- longname: specific humidity
- units: kg/kg
- profile_type:
- name: fixed
- surface_value: 1.e30
+ .. code-block::
+
+ sphum:
+ longname: specific humidity
+ units: kg/kg
+ profile_type:
+ name: fixed
+ surface_value: 1.e30
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
with writable(path) as f:
print(cls._dict_to_str(cfg), file=f)
-
- @staticmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
- """
- return None
-
- @staticmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
- return FORMAT.fieldtable
diff --git a/src/uwtools/config/formats/ini.py b/src/uwtools/config/formats/ini.py
index 87ed85a9e..01ac4ef54 100644
--- a/src/uwtools/config/formats/ini.py
+++ b/src/uwtools/config/formats/ini.py
@@ -11,24 +11,22 @@
class INIConfig(Config):
"""
- Concrete class to handle INI config files.
+ Work with INI configs.
"""
def __init__(self, config: Union[dict, Optional[Path]] = None):
"""
- Construct an INIConfig object.
-
:param config: Config file to load (None => read from stdin), or initial dict.
"""
super().__init__(config)
- self.parse_include()
+ self._parse_include()
# Private methods
@classmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the INI representation of the given dict.
+ Return the INI representation of the given dict.
:param cfg: A dict object.
"""
@@ -45,9 +43,23 @@ def _dict_to_str(cls, cfg: dict) -> str:
parser.write(sio)
return sio.getvalue().strip()
+ @staticmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+ return 2
+
+ @staticmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+ return FORMAT.ini
+
def _load(self, config_file: Optional[Path]) -> dict:
"""
- Reads and parses an INI file.
+ Read and parse an INI file.
See docs for Config._load().
@@ -62,33 +74,19 @@ def _load(self, config_file: Optional[Path]) -> dict:
def dump(self, path: Optional[Path] = None) -> None:
"""
- Dumps the config in INI format.
+ Dump the config in INI format.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
self.dump_dict(self.data, path)
@classmethod
def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary in INI format.
+ Dump a provided config dictionary in INI format.
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
with writable(path) as f:
print(cls._dict_to_str(cfg), file=f)
-
- @staticmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
- """
- return 2
-
- @staticmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
- return FORMAT.ini
diff --git a/src/uwtools/config/formats/nml.py b/src/uwtools/config/formats/nml.py
index 34fbd24a4..93995d136 100644
--- a/src/uwtools/config/formats/nml.py
+++ b/src/uwtools/config/formats/nml.py
@@ -14,7 +14,7 @@
class NMLConfig(Config):
"""
- Concrete class to handle Fortran namelist files.
+ Work with Fortran namelist configs.
"""
def __init__(self, config: Union[dict, Optional[Path]] = None) -> None:
@@ -24,14 +24,14 @@ def __init__(self, config: Union[dict, Optional[Path]] = None) -> None:
:param config: Config file to load (None => read from stdin), or initial dict.
"""
super().__init__(config)
- self.parse_include()
+ self._parse_include()
# Private methods
@classmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the field-table representation of the given dict.
+ Return the field-table representation of the given dict.
:param cfg: A dict object.
"""
@@ -47,13 +47,28 @@ def to_od(d):
nml.write(sio, sort=False)
return sio.getvalue().strip()
+ @staticmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+ return None
+
+ @staticmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+ return FORMAT.nml
+
def _load(self, config_file: Optional[Path]) -> dict:
"""
- Reads and parses a Fortran namelist file.
+ Read and parse a Fortran namelist file.
See docs for Config._load().
:param config_file: Path to config file to load.
+ :return: The parsed namelist data.
"""
with readable(config_file) as f:
config: dict = f90nml.read(f)
@@ -63,33 +78,19 @@ def _load(self, config_file: Optional[Path]) -> dict:
def dump(self, path: Optional[Path]) -> None:
"""
- Dumps the config in Fortran namelist format.
+ Dump the config in Fortran namelist format.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
self.dump_dict(cfg=self.data, path=path)
@classmethod
def dump_dict(cls, cfg: Union[dict, Namelist], path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary in Fortran namelist format.
+ Dump a provided config dictionary in Fortran namelist format.
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
with writable(path) as f:
print(cls._dict_to_str(cfg), file=f)
-
- @staticmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
- """
- return None
-
- @staticmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
- return FORMAT.nml
diff --git a/src/uwtools/config/formats/sh.py b/src/uwtools/config/formats/sh.py
index 0107c6d4d..290b8f4e2 100644
--- a/src/uwtools/config/formats/sh.py
+++ b/src/uwtools/config/formats/sh.py
@@ -12,24 +12,22 @@
class SHConfig(Config):
"""
- Concrete class to handle bash config files.
+ Work with key=value shell configs.
"""
def __init__(self, config: Union[dict, Optional[Path]] = None):
"""
- Construct a SHConfig object.
-
:param config: Config file to load (None => read from stdin), or initial dict.
"""
super().__init__(config)
- self.parse_include()
+ self._parse_include()
# Private methods
@classmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the field-table representation of the given dict.
+ Return the field-table representation of the given dict.
:param cfg: A dict object.
"""
@@ -39,9 +37,23 @@ def _dict_to_str(cls, cfg: dict) -> str:
lines.append("%s=%s" % (key, shlex.quote(str(value))))
return "\n".join(lines)
+ @staticmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+ return 1
+
+ @staticmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+ return FORMAT.sh
+
def _load(self, config_file: Optional[Path]) -> dict:
"""
- Reads and parses key=value lines from shell code.
+ Read and parse key=value lines from shell code.
See docs for Config._load().
@@ -63,9 +75,9 @@ def _load(self, config_file: Optional[Path]) -> dict:
def dump(self, path: Optional[Path]) -> None:
"""
- Dumps the config as key=value lines.
+ Dump the config as key=value lines.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
config_check_depths_dump(config_obj=self, target_format=FORMAT.sh)
self.dump_dict(self.data, path)
@@ -73,24 +85,10 @@ def dump(self, path: Optional[Path]) -> None:
@classmethod
def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary in bash format.
+ Dump a provided config dictionary in bash format.
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
with writable(path) as f:
print(cls._dict_to_str(cfg), file=f)
-
- @staticmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
- """
- return 1
-
- @staticmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
- return FORMAT.sh
diff --git a/src/uwtools/config/formats/yaml.py b/src/uwtools/config/formats/yaml.py
index faa0a67b9..c23ef6200 100644
--- a/src/uwtools/config/formats/yaml.py
+++ b/src/uwtools/config/formats/yaml.py
@@ -48,24 +48,47 @@
class YAMLConfig(Config):
"""
- Concrete class to handle YAML config files.
+ Work with YAML configs.
"""
# Private methods
+ @classmethod
+ def _add_yaml_representers(cls) -> None:
+ """
+ Add representers to the YAML dumper for custom types.
+ """
+ yaml.add_representer(UWYAMLConvert, UWYAMLConvert.represent)
+ yaml.add_representer(Namelist, cls._represent_namelist)
+ yaml.add_representer(OrderedDict, cls._represent_ordereddict)
+
@classmethod
def _dict_to_str(cls, cfg: dict) -> str:
"""
- Returns the YAML representation of the given dict.
+ Return the YAML representation of the given dict.
:param cfg: The in-memory config object.
"""
cls._add_yaml_representers()
return yaml_to_str(cfg)
+ @staticmethod
+ def _get_depth_threshold() -> Optional[int]:
+ """
+ Return the config's depth threshold.
+ """
+ return None
+
+ @staticmethod
+ def _get_format() -> str:
+ """
+ Return the config's format name.
+ """
+ return FORMAT.yaml
+
def _load(self, config_file: Optional[Path]) -> dict:
"""
- Reads and parses a YAML file.
+ Read and parse a YAML file.
See docs for Config._load().
@@ -77,9 +100,10 @@ def _load(self, config_file: Optional[Path]) -> dict:
config = yaml.load(f.read(), Loader=loader)
if isinstance(config, dict):
return config
+ t = type(config).__name__
raise UWConfigError(
- "Parsed a %s value from %s, expected a dict"
- % (type(config).__name__, config_file or "stdin")
+ "Parsed a%s %s value from %s, expected a dict"
+ % ("n" if t[0] in "aeiou" else "", t, config_file or "stdin")
)
except yaml.constructor.ConstructorError as e:
if e.problem:
@@ -94,9 +118,35 @@ def _load(self, config_file: Optional[Path]) -> dict:
msg = str(e)
raise log_and_error(msg) from e
+ @classmethod
+ def _represent_namelist(cls, dumper: yaml.Dumper, data: Namelist) -> yaml.nodes.MappingNode:
+ """
+ Convert an f90nml Namelist to an OrderedDict, then represent as a YAML mapping.
+
+ :param dumper: The YAML dumper.
+ :param data: The f90nml Namelist to serialize.
+ :return: A YAML mapping.
+ """
+ namelist_dict = data.todict()
+ return dumper.represent_mapping("tag:yaml.org,2002:map", namelist_dict)
+
+ @classmethod
+ def _represent_ordereddict(
+ cls, dumper: yaml.Dumper, data: OrderedDict
+ ) -> yaml.nodes.MappingNode:
+ """
+ Recursrively convert an OrderedDict to a dict, then represent as a YAML mapping.
+
+ :param dumper: The YAML dumper.
+ :param data: The OrderedDict to serialize.
+ :return: A YAML mapping.
+ """
+
+ return dumper.represent_mapping("tag:yaml.org,2002:map", from_od(data))
+
def _yaml_include(self, loader: yaml.Loader, node: yaml.SequenceNode) -> dict:
"""
- Returns a dictionary with include tags processed.
+ Return a dictionary with include tags processed.
:param loader: The YAML loader.
:param node: A YAML node.
@@ -107,7 +157,7 @@ def _yaml_include(self, loader: yaml.Loader, node: yaml.SequenceNode) -> dict:
@property
def _yaml_loader(self) -> type[yaml.SafeLoader]:
"""
- Set up the loader with the appropriate constructors.
+ The loader, with appropriate constructors added.
"""
loader = yaml.SafeLoader
loader.add_constructor(INCLUDE_TAG, self._yaml_include)
@@ -120,78 +170,27 @@ def _yaml_loader(self) -> type[yaml.SafeLoader]:
def dump(self, path: Optional[Path] = None) -> None:
"""
- Dumps the config in YAML format.
+ Dump the config in YAML format.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
self.dump_dict(self.data, path)
@classmethod
def dump_dict(cls, cfg: dict, path: Optional[Path] = None) -> None:
"""
- Dumps a provided config dictionary in YAML format.
+ Dump a provided config dictionary in YAML format.
:param cfg: The in-memory config object to dump.
- :param path: Path to dump config to.
+ :param path: Path to dump config to (default: stdout).
"""
with writable(path) as f:
print(cls._dict_to_str(cfg), file=f)
- @staticmethod
- def get_depth_threshold() -> Optional[int]:
- """
- Returns the config's depth threshold.
- """
- return None
-
- @staticmethod
- def get_format() -> str:
- """
- Returns the config's format name.
- """
- return FORMAT.yaml
-
- # Private methods
-
- @classmethod
- def _add_yaml_representers(cls) -> None:
- """
- Add representers to the YAML dumper for custom types.
- """
- yaml.add_representer(UWYAMLConvert, UWYAMLConvert.represent)
- yaml.add_representer(Namelist, cls._represent_namelist)
- yaml.add_representer(OrderedDict, cls._represent_ordereddict)
-
- @classmethod
- def _represent_namelist(cls, dumper: yaml.Dumper, data: Namelist) -> yaml.nodes.MappingNode:
- """
- Convert an f90nml Namelist to an OrderedDict, then represent as a YAML mapping.
-
- :param dumper: The YAML dumper.
- :param data: The f90nml Namelist to serialize.
- """
- namelist_dict = data.todict()
- return dumper.represent_mapping("tag:yaml.org,2002:map", namelist_dict)
-
- @classmethod
- def _represent_ordereddict(
- cls, dumper: yaml.Dumper, data: OrderedDict
- ) -> yaml.nodes.MappingNode:
- """
- Recursrively convert an OrderedDict to a dict, then represent as a YAML mapping.
-
- :param dumper: The YAML dumper.
- :param data: The OrderedDict to serialize.
- """
-
- return dumper.represent_mapping("tag:yaml.org,2002:map", from_od(data))
-
def _write_plain_open_ended(self, *args, **kwargs) -> None:
"""
- Write YAML without ...
-
- end-of-stream marker.
+ Write YAML without the "..." end-of-stream marker.
"""
self.write_plain_base(*args, **kwargs)
self.open_ended = False
diff --git a/src/uwtools/config/jinja2.py b/src/uwtools/config/jinja2.py
index 2c766754f..58683a68a 100644
--- a/src/uwtools/config/jinja2.py
+++ b/src/uwtools/config/jinja2.py
@@ -19,7 +19,7 @@
class J2Template:
"""
- Reads Jinja2 templates from files or strings, and renders them using the user-provided values.
+ Read Jinja2 templates from files or strings, and render them using the user-provided values.
"""
def __init__(
@@ -90,9 +90,7 @@ def render(self) -> str:
@property
def undeclared_variables(self) -> set[str]:
"""
- Returns the names of variables needed to render the template.
-
- :return: Names of variables needed to render the template.
+ The names of variables needed to render the template.
"""
j2_parsed = self._j2env.parse(self._template_str)
return meta.find_undeclared_variables(j2_parsed)
diff --git a/src/uwtools/config/support.py b/src/uwtools/config/support.py
index 632405d89..96b433862 100644
--- a/src/uwtools/config/support.py
+++ b/src/uwtools/config/support.py
@@ -49,7 +49,7 @@ def format_to_config(fmt: str) -> Type:
def from_od(d: Union[OrderedDict, dict]) -> dict:
"""
- Returns a (nested) dict with content equivalent to the given (nested) OrderedDict.
+ Return a (nested) dict with content equivalent to the given (nested) OrderedDict.
:param d: A (possibly nested) OrderedDict.
"""
@@ -69,7 +69,7 @@ def log_and_error(msg: str) -> Exception:
def yaml_to_str(cfg: dict) -> str:
"""
- Returns a uwtools-conventional YAML representation of the given dict.
+ Return a uwtools-conventional YAML representation of the given dict.
:param cfg: A dict object.
"""
diff --git a/src/uwtools/config/tools.py b/src/uwtools/config/tools.py
index dfda5b7cb..2d99e651d 100644
--- a/src/uwtools/config/tools.py
+++ b/src/uwtools/config/tools.py
@@ -149,7 +149,7 @@ def _ensure_format(
:raises: UWError if the format cannot be determined.
"""
if isinstance(config, Config):
- return config.get_format()
+ return config._get_format() # pylint: disable=protected-access
if isinstance(config, Path):
return fmt or get_file_format(config)
if isinstance(config, dict):
@@ -161,7 +161,7 @@ def _ensure_format(
def _print_config_section(config: dict, key_path: list[str]) -> None:
"""
- Prints the contents of the located subtree as key=value pairs, one per line.
+ Print the contents of the located subtree as key=value pairs, one per line.
:param config: A config.
:param key_path: Path of keys to subsection of config file.
@@ -211,7 +211,8 @@ def _realize_config_output_setup(
"""
output_format = _ensure_format("output", output_format, output_file)
log.debug("Writing output to %s" % (output_file or "stdout"))
- _validate_format("output", output_format, input_obj.get_format())
+ fmt = input_obj._get_format() # pylint: disable=protected-access
+ _validate_format("output", output_format, fmt)
output_data = input_obj.data
if key_path is not None:
for key in key_path:
@@ -235,19 +236,21 @@ def _realize_config_update(
"""
if update_config or update_format:
update_format = _ensure_format("update", update_format, update_config)
+ fmt = lambda x: x._get_format() # pylint: disable=protected-access
+ depth_ = lambda x: x._depth # pylint: disable=protected-access
if not update_config:
log.debug("Reading update from stdin")
- _validate_format("update", update_format, input_obj.get_format())
+ _validate_format("update", update_format, fmt(input_obj))
update_obj: Config = (
update_config
if isinstance(update_config, Config)
else format_to_config(update_format)(config=update_config)
)
- log.debug("Initial input config depth: %s", input_obj.depth)
- log.debug("Update config depth: %s", update_obj.depth)
- config_check_depths_update(update_obj, input_obj.get_format())
- input_obj.update_values(update_obj)
- log.debug("Final input config depth: %s", input_obj.depth)
+ log.debug("Initial input config depth: %s", depth_(input_obj))
+ log.debug("Update config depth: %s", depth_(update_obj))
+ config_check_depths_update(update_obj, fmt(input_obj))
+ input_obj.update_from(update_obj)
+ log.debug("Final input config depth: %s", depth_(input_obj))
return input_obj
@@ -257,7 +260,9 @@ def _realize_config_values_needed(input_obj: Config) -> None:
:param input_obj: The config to update.
"""
- complete, template = input_obj.characterize_values(input_obj.data, parent="")
+ complete, template = input_obj._characterize_values( # pylint: disable=protected-access
+ input_obj.data, parent=""
+ )
if complete:
log.info("Keys that are complete:")
for var in complete:
@@ -285,7 +290,8 @@ def _validate_depth(
"""
target_class = format_to_config(target_format)
config = config_obj.data if isinstance(config_obj, Config) else config_obj
- if bad_depth(target_class.get_depth_threshold(), depth(config)):
+ depth_threshold = target_class._get_depth_threshold() # pylint: disable=protected-access
+ if bad_depth(depth_threshold, depth(config)):
raise UWConfigError(
"Cannot %s depth-%s config to type-'%s' config" % (action, depth(config), target_format)
)
diff --git a/src/uwtools/config/validator.py b/src/uwtools/config/validator.py
index 0803598dd..42b723ed6 100644
--- a/src/uwtools/config/validator.py
+++ b/src/uwtools/config/validator.py
@@ -3,6 +3,7 @@
"""
import json
+from functools import cache
from pathlib import Path
from typing import Optional, Union
@@ -19,9 +20,30 @@
# Public functions
+def bundle(schema: dict) -> dict:
+ """
+ Bundle a schema by dereferencing links to other schemas.
+
+ :param schema: A JSON Schema.
+ :returns: The bundled schema.
+ """
+ key = "$ref"
+ bundled = {}
+ for k, v in schema.items():
+ if isinstance(v, dict):
+ if list(v.keys()) == [key] and v[key].startswith("urn:uwtools:"):
+ # i.e. the current key's value is of the form: {"$ref": "urn:uwtools:.*"}
+ bundled[k] = bundle(_registry().get_or_retrieve(v[key]).value.contents)
+ else:
+ bundled[k] = bundle(v)
+ else:
+ bundled[k] = v
+ return bundled
+
+
def get_schema_file(schema_name: str) -> Path:
"""
- Returns the path to the JSON Schema file for a given name.
+ Return the path to the JSON Schema file for a given name.
:param schema_name: Name of uwtools schema to validate the config against.
"""
@@ -95,13 +117,10 @@ def _prep_config(config: Union[dict, YAMLConfig, Optional[Path]]) -> YAMLConfig:
return cfgobj
-def _validation_errors(config: Union[dict, list], schema: dict) -> list[ValidationError]:
+@cache
+def _registry() -> Registry:
"""
- Identify schema-validation errors.
-
- :param config: A config to validate.
- :param schema: JSON Schema to validate the config against.
- :return: Any validation errors.
+ Return a JSON Schema registry resolving urn:uwtools:* references.
"""
# See https://github.com/python-jsonschema/referencing/issues/61 about typing issues.
@@ -111,6 +130,16 @@ def retrieve(uri: str) -> Resource:
with open(resource_path(f"jsonschema/{name}.jsonschema"), "r", encoding="utf-8") as f:
return Resource(contents=json.load(f), specification=DRAFT202012) # type: ignore
- registry = Registry(retrieve=retrieve) # type: ignore
- validator = Draft202012Validator(schema, registry=registry)
+ return Registry(retrieve=retrieve) # type: ignore
+
+
+def _validation_errors(config: Union[dict, list], schema: dict) -> list[ValidationError]:
+ """
+ Identify schema-validation errors.
+
+ :param config: A config to validate.
+ :param schema: JSON Schema to validate the config against.
+ :return: Any validation errors.
+ """
+ validator = Draft202012Validator(schema, registry=_registry())
return list(validator.iter_errors(config))
diff --git a/src/uwtools/drivers/cdeps.py b/src/uwtools/drivers/cdeps.py
index 6841c675a..a3a481797 100644
--- a/src/uwtools/drivers/cdeps.py
+++ b/src/uwtools/drivers/cdeps.py
@@ -24,7 +24,7 @@ class CDEPS(AssetsCycleBased):
@tasks
def atm(self):
"""
- Create data atmosphere configuration with all required content.
+ The data atmosphere configuration with all required content.
"""
yield self.taskname("data atmosphere configuration")
yield [
@@ -35,7 +35,7 @@ def atm(self):
@task
def atm_nml(self):
"""
- Create data atmosphere Fortran namelist file (datm_in).
+ The data atmosphere Fortran namelist file (datm_in).
"""
fn = "datm_in"
yield self.taskname(f"namelist file {fn}")
@@ -47,7 +47,7 @@ def atm_nml(self):
@task
def atm_stream(self):
"""
- Create data atmosphere stream config file (datm.streams).
+ The data atmosphere stream config file (datm.streams).
"""
fn = "datm.streams"
yield self.taskname(f"stream file {fn}")
@@ -60,7 +60,7 @@ def atm_stream(self):
@tasks
def ocn(self):
"""
- Create data ocean configuration with all required content.
+ The data ocean configuration with all required content.
"""
yield self.taskname("data atmosphere configuration")
yield [
@@ -71,7 +71,7 @@ def ocn(self):
@task
def ocn_nml(self):
"""
- Create data ocean Fortran namelist file (docn_in).
+ The data ocean Fortran namelist file (docn_in).
"""
fn = "docn_in"
yield self.taskname(f"namelist file {fn}")
@@ -83,7 +83,7 @@ def ocn_nml(self):
@task
def ocn_stream(self):
"""
- Create data ocean stream config file (docn.streams).
+ The data ocean stream config file (docn.streams).
"""
fn = "docn.streams"
yield self.taskname(f"stream file {fn}")
@@ -95,10 +95,10 @@ def ocn_stream(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.cdeps
diff --git a/src/uwtools/drivers/chgres_cube.py b/src/uwtools/drivers/chgres_cube.py
index cb110bb8e..669860b07 100644
--- a/src/uwtools/drivers/chgres_cube.py
+++ b/src/uwtools/drivers/chgres_cube.py
@@ -91,10 +91,10 @@ def runscript(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.chgrescube
diff --git a/src/uwtools/drivers/driver.py b/src/uwtools/drivers/driver.py
index c84abb088..1a60a6d14 100644
--- a/src/uwtools/drivers/driver.py
+++ b/src/uwtools/drivers/driver.py
@@ -19,13 +19,19 @@
from uwtools.config.formats.base import Config
from uwtools.config.formats.yaml import YAMLConfig
from uwtools.config.tools import walk_key_path
-from uwtools.config.validator import get_schema_file, validate, validate_external, validate_internal
+from uwtools.config.validator import (
+ bundle,
+ get_schema_file,
+ validate,
+ validate_external,
+ validate_internal,
+)
from uwtools.exceptions import UWConfigError
from uwtools.logging import log
from uwtools.scheduler import JobScheduler
from uwtools.strings import STR
from uwtools.utils.file import writable
-from uwtools.utils.processing import execute
+from uwtools.utils.processing import run_shell_cmd
# NB: Class docstrings are programmatically defined.
@@ -56,9 +62,9 @@ def __init__(
self._config_full: dict = config_input.data
self._config_intermediate, _ = walk_key_path(self._config_full, key_path or [])
try:
- self._config: dict = self._config_intermediate[self.driver_name]
+ self._config: dict = self._config_intermediate[self.driver_name()]
except KeyError as e:
- raise UWConfigError("Required '%s' block missing in config" % self.driver_name) from e
+ raise UWConfigError("Required '%s' block missing in config" % self.driver_name()) from e
if controller:
self._config[STR.rundir] = self._config_intermediate[controller][STR.rundir]
self._validate(schema_file)
@@ -74,7 +80,7 @@ def __repr__(self) -> str:
return " ".join(filter(None, [str(self), cycle, leadtime, "in", self.config[STR.rundir]]))
def __str__(self) -> str:
- return self.driver_name
+ return self.driver_name()
@property
def config(self) -> dict:
@@ -97,9 +103,17 @@ def rundir(self) -> Path:
"""
return Path(self.config[STR.rundir]).absolute()
+ @classmethod
+ def schema(cls) -> dict:
+ """
+ Return the driver's schema.
+ """
+ with open(get_schema_file(schema_name=cls._schema_name()), "r", encoding="utf-8") as f:
+ return bundle(json.load(f))
+
def taskname(self, suffix: str) -> str:
"""
- Returns a common tag for graph-task log messages.
+ Return a common tag for graph-task log messages.
:param suffix: Log-string suffix.
"""
@@ -110,7 +124,7 @@ def taskname(self, suffix: str) -> str:
if cycle and leadtime is not None
else cycle.strftime("%Y%m%d %HZ") if cycle else None
)
- return " ".join(filter(None, [timestr, self.driver_name, suffix]))
+ return " ".join(filter(None, [timestr, self.driver_name(), suffix]))
# Workflow tasks
@@ -139,7 +153,7 @@ def _create_user_updated_config(
user_values = config_values.get(STR.updatevalues, {})
if base_file := config_values.get(STR.basefile):
cfgobj = config_class(base_file)
- cfgobj.update_values(user_values)
+ cfgobj.update_from(user_values)
cfgobj.dereference()
config = cfgobj.data
dump = partial(cfgobj.dump, path)
@@ -154,11 +168,11 @@ def _create_user_updated_config(
# Public helper methods
- @property
+ @classmethod
@abstractmethod
- def driver_name(self) -> str:
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
# Private helper methods
@@ -167,7 +181,7 @@ def _namelist_schema(
self, config_keys: Optional[list[str]] = None, schema_keys: Optional[list[str]] = None
) -> dict:
"""
- Returns the (sub)schema for validating the driver's namelist content.
+ Return the (sub)schema for validating the driver's namelist content.
:param config_keys: Keys leading to the namelist block in the driver config.
:param schema_keys: Keys leading to the namelist-validating (sub)schema.
@@ -177,12 +191,12 @@ def _namelist_schema(
for config_key in config_keys or [STR.namelist]:
nmlcfg = nmlcfg[config_key]
if nmlcfg.get(STR.validate, True):
- schema_file = get_schema_file(schema_name=self.driver_name.replace("_", "-"))
+ schema_file = get_schema_file(schema_name=self._schema_name())
with open(schema_file, "r", encoding="utf-8") as f:
schema = json.load(f)
for schema_key in schema_keys or [
STR.properties,
- self.driver_name,
+ self.driver_name(),
STR.properties,
STR.namelist,
STR.properties,
@@ -191,6 +205,13 @@ def _namelist_schema(
schema = schema[schema_key]
return schema
+ @classmethod
+ def _schema_name(cls) -> str:
+ """
+ Return the filename stem for this driver's schema file.
+ """
+ return cls.driver_name().replace("_", "-")
+
def _validate(self, schema_file: Optional[Path] = None) -> None:
"""
Perform all necessary schema validation.
@@ -201,9 +222,7 @@ def _validate(self, schema_file: Optional[Path] = None) -> None:
if schema_file:
validate_external(schema_file=schema_file, config=self._config_intermediate)
else:
- validate_internal(
- schema_name=self.driver_name.replace("_", "-"), config=self._config_intermediate
- )
+ validate_internal(schema_name=self._schema_name(), config=self._config_intermediate)
class AssetsCycleBased(Assets):
@@ -380,14 +399,14 @@ def _run_via_local_execution(self):
yield asset(path, path.is_file)
yield self.provisioned_rundir()
cmd = "{x} >{x}.out 2>&1".format(x=self._runscript_path)
- execute(cmd=cmd, cwd=self.rundir, log_output=True)
+ run_shell_cmd(cmd=cmd, cwd=self.rundir, log_output=True)
# Private helper methods
@property
def _run_resources(self) -> dict[str, Any]:
"""
- Returns platform configuration data.
+ The platform configuration data.
"""
if not (platform := self._config_intermediate.get("platform")):
raise UWConfigError("Required 'platform' block missing in config")
@@ -404,7 +423,7 @@ def _run_resources(self) -> dict[str, Any]:
@property
def _runcmd(self) -> str:
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
execution = self.config.get(STR.execution, {})
mpiargs = execution.get(STR.mpiargs, [])
@@ -423,7 +442,7 @@ def _runscript(
scheduler: Optional[JobScheduler] = None,
) -> str:
"""
- Returns a driver runscript.
+ Return a driver runscript.
:param execution: Statements to execute.
:param envcmds: Shell commands to set up runtime environment.
@@ -461,14 +480,14 @@ def _runscript_done_file(self):
@property
def _runscript_path(self) -> Path:
"""
- Returns the path to the runscript.
+ The path to the runscript.
"""
- return self.rundir / f"runscript.{self.driver_name}"
+ return self.rundir / f"runscript.{self.driver_name()}"
@property
def _scheduler(self) -> JobScheduler:
"""
- Returns the job scheduler specified by the platform information.
+ The job scheduler specified by the platform information.
"""
return JobScheduler.get_scheduler(self._run_resources)
@@ -479,12 +498,7 @@ def _validate(self, schema_file: Optional[Path] = None) -> None:
:param schema_file: The JSON Schema file to use for validation.
:raises: UWConfigError if config fails validation.
"""
- if schema_file:
- validate_external(schema_file=schema_file, config=self._config_intermediate)
- else:
- validate_internal(
- schema_name=self.driver_name.replace("_", "-"), config=self._config_intermediate
- )
+ Assets._validate(self, schema_file)
validate_internal(schema_name=STR.platform, config=self._config_intermediate)
def _write_runscript(self, path: Path, envvars: Optional[dict[str, str]] = None) -> None:
diff --git a/src/uwtools/drivers/esg_grid.py b/src/uwtools/drivers/esg_grid.py
index 9b1082056..0d77a7da8 100644
--- a/src/uwtools/drivers/esg_grid.py
+++ b/src/uwtools/drivers/esg_grid.py
@@ -51,10 +51,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.esggrid
diff --git a/src/uwtools/drivers/filter_topo.py b/src/uwtools/drivers/filter_topo.py
index 3b852a35d..3cb396a5d 100644
--- a/src/uwtools/drivers/filter_topo.py
+++ b/src/uwtools/drivers/filter_topo.py
@@ -62,10 +62,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.filtertopo
diff --git a/src/uwtools/drivers/fv3.py b/src/uwtools/drivers/fv3.py
index 9d06e4de5..e417f58d1 100644
--- a/src/uwtools/drivers/fv3.py
+++ b/src/uwtools/drivers/fv3.py
@@ -178,10 +178,10 @@ def runscript(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.fv3
diff --git a/src/uwtools/drivers/global_equiv_resol.py b/src/uwtools/drivers/global_equiv_resol.py
index 58264fdb5..25b3ef8a6 100644
--- a/src/uwtools/drivers/global_equiv_resol.py
+++ b/src/uwtools/drivers/global_equiv_resol.py
@@ -40,10 +40,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.globalequivresol
@@ -52,7 +52,7 @@ def driver_name(self) -> str:
@property
def _runcmd(self):
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
executable = self.config[STR.execution][STR.executable]
input_file_path = self.config["input_grid_file"]
diff --git a/src/uwtools/drivers/ioda.py b/src/uwtools/drivers/ioda.py
index 1a740506b..533b7faa6 100644
--- a/src/uwtools/drivers/ioda.py
+++ b/src/uwtools/drivers/ioda.py
@@ -31,10 +31,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.ioda
@@ -43,14 +43,14 @@ def driver_name(self) -> str:
@property
def _config_fn(self) -> str:
"""
- Returns the name of the config file used in execution.
+ The name of the config file used in execution.
"""
return "ioda.yaml"
@property
def _runcmd(self) -> str:
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
executable = self.config[STR.execution][STR.executable]
jedi_config = str(self.rundir / self._config_fn)
diff --git a/src/uwtools/drivers/jedi.py b/src/uwtools/drivers/jedi.py
index 12acbcf0e..a2467cdef 100644
--- a/src/uwtools/drivers/jedi.py
+++ b/src/uwtools/drivers/jedi.py
@@ -56,10 +56,10 @@ def validate_only(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.jedi
@@ -68,14 +68,14 @@ def driver_name(self) -> str:
@property
def _config_fn(self) -> str:
"""
- Returns the name of the config file used in execution.
+ The name of the config file used in execution.
"""
return "jedi.yaml"
@property
def _runcmd(self) -> str:
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
execution = self.config[STR.execution]
jedi_config = self.rundir / self._config_fn
diff --git a/src/uwtools/drivers/jedi_base.py b/src/uwtools/drivers/jedi_base.py
index f517a4ce5..1d4e1309a 100644
--- a/src/uwtools/drivers/jedi_base.py
+++ b/src/uwtools/drivers/jedi_base.py
@@ -72,5 +72,5 @@ def provisioned_rundir(self):
@abstractmethod
def _config_fn(self) -> str:
"""
- Returns the name of the config file used in execution.
+ The name of the config file used in execution.
"""
diff --git a/src/uwtools/drivers/make_hgrid.py b/src/uwtools/drivers/make_hgrid.py
index 78f0f6baa..28f275953 100644
--- a/src/uwtools/drivers/make_hgrid.py
+++ b/src/uwtools/drivers/make_hgrid.py
@@ -26,10 +26,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.makehgrid
@@ -38,7 +38,7 @@ def driver_name(self) -> str:
@property
def _runcmd(self):
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
executable = self.config[STR.execution][STR.executable]
config = self.config["config"]
diff --git a/src/uwtools/drivers/make_solo_mosaic.py b/src/uwtools/drivers/make_solo_mosaic.py
index 5b954333b..d9598edac 100644
--- a/src/uwtools/drivers/make_solo_mosaic.py
+++ b/src/uwtools/drivers/make_solo_mosaic.py
@@ -28,18 +28,18 @@ def provisioned_rundir(self):
def taskname(self, suffix: str) -> str:
"""
- Returns a common tag for graph-task log messages.
+ Return a common tag for graph-task log messages.
:param suffix: Log-string suffix.
"""
- return "%s %s" % (self.driver_name, suffix)
+ return "%s %s" % (self.driver_name(), suffix)
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.makesolomosaic
@@ -48,7 +48,7 @@ def driver_name(self) -> str:
@property
def _runcmd(self):
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
executable = self.config[STR.execution][STR.executable]
flags = " ".join(f"--{k} {v}" for k, v in self.config["config"].items())
diff --git a/src/uwtools/drivers/mpas.py b/src/uwtools/drivers/mpas.py
index 2b250541a..c1953f822 100644
--- a/src/uwtools/drivers/mpas.py
+++ b/src/uwtools/drivers/mpas.py
@@ -68,10 +68,10 @@ def namelist_file(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.mpas
diff --git a/src/uwtools/drivers/mpas_init.py b/src/uwtools/drivers/mpas_init.py
index 547e88de3..47d954fbd 100644
--- a/src/uwtools/drivers/mpas_init.py
+++ b/src/uwtools/drivers/mpas_init.py
@@ -70,10 +70,10 @@ def namelist_file(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.mpasinit
diff --git a/src/uwtools/drivers/orog_gsl.py b/src/uwtools/drivers/orog_gsl.py
index 46d53beac..bfde683e7 100644
--- a/src/uwtools/drivers/orog_gsl.py
+++ b/src/uwtools/drivers/orog_gsl.py
@@ -72,10 +72,10 @@ def topo_data_30s(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.oroggsl
@@ -84,7 +84,7 @@ def driver_name(self) -> str:
@property
def _runcmd(self):
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
inputs = [str(self.config["config"][k]) for k in ("tile", "resolution", "halo")]
executable = self.config[STR.execution][STR.executable]
diff --git a/src/uwtools/drivers/schism.py b/src/uwtools/drivers/schism.py
index 76e71525e..1f6fa30ef 100644
--- a/src/uwtools/drivers/schism.py
+++ b/src/uwtools/drivers/schism.py
@@ -47,10 +47,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.schism
diff --git a/src/uwtools/drivers/sfc_climo_gen.py b/src/uwtools/drivers/sfc_climo_gen.py
index 3d1ce53d4..035ccc07f 100644
--- a/src/uwtools/drivers/sfc_climo_gen.py
+++ b/src/uwtools/drivers/sfc_climo_gen.py
@@ -54,10 +54,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.sfcclimogen
diff --git a/src/uwtools/drivers/shave.py b/src/uwtools/drivers/shave.py
index 018d39813..868d38982 100644
--- a/src/uwtools/drivers/shave.py
+++ b/src/uwtools/drivers/shave.py
@@ -26,10 +26,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.shave
@@ -38,7 +38,7 @@ def driver_name(self) -> str:
@property
def _runcmd(self):
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
executable = self.config[STR.execution][STR.executable]
config = self.config["config"]
diff --git a/src/uwtools/drivers/support.py b/src/uwtools/drivers/support.py
index e01707835..012a6887b 100644
--- a/src/uwtools/drivers/support.py
+++ b/src/uwtools/drivers/support.py
@@ -12,14 +12,14 @@
def graph() -> str:
"""
- Returns Graphviz DOT code for the most recently executed task.
+ Return Graphviz DOT code for the most recently executed task.
"""
return _iotaa.graph()
def set_driver_docstring(driver_class: Type) -> None:
"""
- Appends inherited parameter descriptions to the driver's own docstring.
+ Append inherited parameter descriptions to the driver's own docstring.
:param driver_class: The class whose docstring to update.
"""
@@ -36,7 +36,7 @@ def set_driver_docstring(driver_class: Type) -> None:
def tasks(driver_class: DriverT) -> dict[str, str]:
"""
- Returns a mapping from task names to their one-line descriptions.
+ Return a mapping from task names to their one-line descriptions.
:param driver_class: Class of driver object to instantiate.
"""
diff --git a/src/uwtools/drivers/ungrib.py b/src/uwtools/drivers/ungrib.py
index db3b7ab4c..22715fd22 100644
--- a/src/uwtools/drivers/ungrib.py
+++ b/src/uwtools/drivers/ungrib.py
@@ -104,10 +104,10 @@ def vtable(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.ungrib
@@ -130,7 +130,9 @@ def _gribfile(self, infile: Path, link: Path):
def _ext(n):
"""
- Maps integers to 3-letter string.
+ Return a 3-letter representation of the given integer.
+
+ :param n: The integer to convert to a string representation.
"""
b = 26
return "{:A>3}".format(("" if n < b else _ext(n // b)) + chr(65 + n % b))[-3:]
diff --git a/src/uwtools/drivers/upp.py b/src/uwtools/drivers/upp.py
index d6fa799b4..eed851355 100644
--- a/src/uwtools/drivers/upp.py
+++ b/src/uwtools/drivers/upp.py
@@ -74,10 +74,10 @@ def provisioned_rundir(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.upp
@@ -86,14 +86,14 @@ def driver_name(self) -> str:
@property
def _namelist_path(self) -> Path:
"""
- Path to the namelist file.
+ The path to the namelist file.
"""
return self.rundir / "itag"
@property
def _runcmd(self) -> str:
"""
- Returns the full command-line component invocation.
+ The full command-line component invocation.
"""
execution = self.config.get(STR.execution, {})
mpiargs = execution.get(STR.mpiargs, [])
diff --git a/src/uwtools/drivers/ww3.py b/src/uwtools/drivers/ww3.py
index 33b9af79c..a177eb438 100644
--- a/src/uwtools/drivers/ww3.py
+++ b/src/uwtools/drivers/ww3.py
@@ -61,10 +61,10 @@ def restart_directory(self):
# Public helper methods
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
"""
- Returns the name of this driver.
+ The name of this driver.
"""
return STR.ww3
diff --git a/src/uwtools/file.py b/src/uwtools/fs.py
similarity index 52%
rename from src/uwtools/file.py
rename to src/uwtools/fs.py
index 5b8162d8b..c5bd29b68 100644
--- a/src/uwtools/file.py
+++ b/src/uwtools/fs.py
@@ -1,9 +1,9 @@
"""
-File handling.
+File and directory staging.
"""
import datetime as dt
-from functools import cached_property
+from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Union
@@ -13,13 +13,14 @@
from uwtools.config.validator import validate_internal
from uwtools.exceptions import UWConfigError
from uwtools.logging import log
+from uwtools.strings import STR
from uwtools.utils.api import str2path
-from uwtools.utils.tasks import filecopy, symlink
+from uwtools.utils.tasks import directory, filecopy, symlink
-class Stager:
+class Stager(ABC):
"""
- The base class for staging files.
+ The base class for staging files and directories.
"""
def __init__(
@@ -32,7 +33,7 @@ def __init__(
dry_run: bool = False,
) -> None:
"""
- Handle files.
+ Stage files and directories.
:param config: YAML-file path, or dict (read stdin if missing or None).
:param target_dir: Path to target directory.
@@ -43,39 +44,41 @@ def __init__(
:raises: UWConfigError if config fails validation.
"""
dryrun(enable=dry_run)
- self._target_dir = str2path(target_dir)
- self._config = YAMLConfig(config=str2path(config))
self._keys = keys or []
- self._config.dereference(
+ self._target_dir = str2path(target_dir)
+ yaml_config = YAMLConfig(config=str2path(config))
+ yaml_config.dereference(
context={
**({"cycle": cycle} if cycle else {}),
**({"leadtime": leadtime} if leadtime else {}),
- **self._config.data,
+ **yaml_config.data,
}
)
+ self._config = yaml_config.data
+ self._set_config_block()
self._validate()
+ self._check_paths()
- def _check_dst_paths(self, cfg: dict[str, str]) -> None:
+ def _check_paths(self) -> None:
"""
- Check that all destination paths are absolute if no target directory is specified.
+ Check that all paths are absolute if no target directory is specified.
- :parm cfg: The dst/linkname -> src/target map.
+ :parm paths: The paths to check.
:raises: UWConfigError if no target directory is specified and a relative path is.
"""
if not self._target_dir:
errmsg = "Relative path '%s' requires the target directory to be specified"
- for dst in cfg.keys():
+ for dst in self._dst_paths:
if not Path(dst).is_absolute():
raise UWConfigError(errmsg % dst)
- @cached_property
- def _file_map(self) -> dict:
+ def _set_config_block(self) -> None:
"""
- Navigate keys to file dst/src config block.
+ Navigate keys to a config block.
- :return: The dst/src file block from a potentially larger config.
+ :raises: UWConfigError if no target directory is specified and a relative path is.
"""
- cfg = self._config.data
+ cfg = self._config
nav = []
for key in self._keys:
nav.append(key)
@@ -84,22 +87,54 @@ def _file_map(self) -> dict:
log.debug("Following config key '%s'", key)
cfg = cfg[key]
if not isinstance(cfg, dict):
- raise UWConfigError("No file map found at key path: %s" % " -> ".join(self._keys))
- self._check_dst_paths(cfg)
- return cfg
+ msg = "Expected block not found at key path: %s" % " -> ".join(self._keys)
+ raise UWConfigError(msg)
+ self._config = cfg
+
+ @property
+ @abstractmethod
+ def _dst_paths(self) -> list[str]:
+ """
+ The paths to files or directories to create.
+ """
+
+ @property
+ @abstractmethod
+ def _schema(self) -> str:
+ """
+ The name of the schema to use for config validation.
+ """
- def _validate(self) -> bool:
+ def _validate(self) -> None:
"""
Validate config against its schema.
- :return: True if config passes validation.
:raises: UWConfigError if config fails validation.
"""
- validate_internal(schema_name="files-to-stage", config=self._file_map)
- return True
+ validate_internal(schema_name=self._schema, config=self._config)
-class Copier(Stager):
+class FileStager(Stager):
+ """
+ Stage files.
+ """
+
+ @property
+ def _dst_paths(self) -> list[str]:
+ """
+ The paths to files to create.
+ """
+ return list(self._config.keys())
+
+ @property
+ def _schema(self) -> str:
+ """
+ The name of the schema to use for config validation.
+ """
+ return "files-to-stage"
+
+
+class Copier(FileStager):
"""
Stage files by copying.
"""
@@ -111,10 +146,10 @@ def go(self):
"""
dst = lambda k: Path(self._target_dir / k if self._target_dir else k)
yield "File copies"
- yield [filecopy(src=Path(v), dst=dst(k)) for k, v in self._file_map.items()]
+ yield [filecopy(src=Path(v), dst=dst(k)) for k, v in self._config.items()]
-class Linker(Stager):
+class Linker(FileStager):
"""
Stage files by linking.
"""
@@ -126,4 +161,36 @@ def go(self):
"""
linkname = lambda k: Path(self._target_dir / k if self._target_dir else k)
yield "File links"
- yield [symlink(target=Path(v), linkname=linkname(k)) for k, v in self._file_map.items()]
+ yield [symlink(target=Path(v), linkname=linkname(k)) for k, v in self._config.items()]
+
+
+class MakeDirs(Stager):
+ """
+ Make directories.
+ """
+
+ @tasks
+ def go(self):
+ """
+ Make directories.
+ """
+ yield "Directories"
+ yield [
+ directory(path=Path(self._target_dir / p if self._target_dir else p))
+ for p in self._config[STR.makedirs]
+ ]
+
+ @property
+ def _dst_paths(self) -> list[str]:
+ """
+ The paths to directories to create.
+ """
+ paths: list[str] = self._config[STR.makedirs]
+ return paths
+
+ @property
+ def _schema(self) -> str:
+ """
+ The name of the schema to use for config validation.
+ """
+ return "makedirs"
diff --git a/src/uwtools/resources/info.json b/src/uwtools/resources/info.json
index 3fc5690e5..ecb08defa 100644
--- a/src/uwtools/resources/info.json
+++ b/src/uwtools/resources/info.json
@@ -1,4 +1,4 @@
{
- "version": "2.4.1",
+ "version": "2.4.2",
"buildnum": "0"
}
diff --git a/src/uwtools/resources/jsonschema/makedirs.jsonschema b/src/uwtools/resources/jsonschema/makedirs.jsonschema
new file mode 100644
index 000000000..0fee1c309
--- /dev/null
+++ b/src/uwtools/resources/jsonschema/makedirs.jsonschema
@@ -0,0 +1,16 @@
+{
+ "additionalProperties": false,
+ "properties": {
+ "makedirs": {
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": [
+ "makedirs"
+ ],
+ "type": "object"
+}
diff --git a/src/uwtools/rocoto.py b/src/uwtools/rocoto.py
index a04cda373..4bfb6fb3b 100644
--- a/src/uwtools/rocoto.py
+++ b/src/uwtools/rocoto.py
@@ -218,6 +218,8 @@ def _add_task_dependency_child(self, e: _Element, config: dict, tag: str) -> Non
self._add_task_dependency_datadep(e, config)
elif tag == STR.taskdep:
self._add_task_dependency_taskdep(e, config)
+ elif tag == STR.metataskdep:
+ self._add_task_dependency_metataskdep(e, config)
elif tag == STR.taskvalid:
self._add_task_dependency_taskvalid(e, config)
elif tag == STR.timedep:
@@ -235,6 +237,15 @@ def _add_task_dependency_datadep(self, e: _Element, config: dict) -> None:
e = self._add_compound_time_string(e, config[STR.value], STR.datadep)
self._set_attrs(e, config)
+ def _add_task_dependency_metataskdep(self, e: _Element, config: dict) -> None:
+ """
+ Add a element to the .
+
+ :param e: The parent element to add the new element to.
+ :param config: Configuration data for this element.
+ """
+ self._set_attrs(SubElement(e, STR.metataskdep), config)
+
def _add_task_dependency_sh(
self, e: _Element, config: dict, name_attr: Optional[str] = None
) -> None:
@@ -353,7 +364,7 @@ def _config_validate(self, config: Union[dict, YAMLConfig, Optional[Path]]) -> N
@property
def _doctype(self) -> Optional[str]:
"""
- Generate the block with definitions.
+ The block with definitions.
:return: The block if entities are defined, otherwise None.
"""
@@ -437,6 +448,7 @@ class STR:
log: str = "log"
memory: str = "memory"
metatask: str = "metatask"
+ metataskdep: str = "metataskdep"
name: str = "name"
nand: str = "nand"
native: str = "native"
diff --git a/src/uwtools/scheduler.py b/src/uwtools/scheduler.py
index ce4cc0755..7861387fd 100644
--- a/src/uwtools/scheduler.py
+++ b/src/uwtools/scheduler.py
@@ -14,7 +14,7 @@
from uwtools.exceptions import UWConfigError
from uwtools.logging import log
from uwtools.strings import STR
-from uwtools.utils.processing import execute
+from uwtools.utils.processing import run_shell_cmd
class JobScheduler(ABC):
@@ -32,7 +32,7 @@ def __init__(self, props: dict[str, Any]):
@property
def directives(self) -> list[str]:
"""
- Returns resource-request scheduler directives.
+ The resource-request scheduler directives.
"""
pre, sep = self._prefix, self._directive_separator
ds = []
@@ -53,7 +53,7 @@ def directives(self) -> list[str]:
@staticmethod
def get_scheduler(props: Mapping) -> JobScheduler:
"""
- Returns a configured job scheduler.
+ Return a configured job scheduler.
:param props: Configuration settings for job scheduler.
:return: A configured job scheduler.
@@ -71,7 +71,7 @@ def get_scheduler(props: Mapping) -> JobScheduler:
def submit_job(self, runscript: Path, submit_file: Optional[Path] = None) -> bool:
"""
- Submits a job to the scheduler.
+ Submit a job to the scheduler.
:param runscript: Path to the runscript.
:param submit_file: Path to file to write output of submit command to.
@@ -80,7 +80,7 @@ def submit_job(self, runscript: Path, submit_file: Optional[Path] = None) -> boo
cmd = f"{self._submit_cmd} {runscript}"
if submit_file:
cmd += " 2>&1 | tee %s" % submit_file
- success, _ = execute(cmd=cmd, cwd=f"{runscript.parent}")
+ success, _ = run_shell_cmd(cmd=cmd, cwd=f"{runscript.parent}")
return success
# Private methods
@@ -89,34 +89,34 @@ def submit_job(self, runscript: Path, submit_file: Optional[Path] = None) -> boo
@abstractmethod
def _directive_separator(self) -> str:
"""
- Returns the character used to separate directive keys and values.
+ The character used to separate directive keys and values.
"""
@property
@abstractmethod
def _forbidden_directives(self) -> list[str]:
"""
- Returns directives that this scheduler does not support.
+ The directives that this scheduler does not support.
"""
@property
@abstractmethod
def _managed_directives(self) -> dict[str, Any]:
"""
- Returns a mapping from canonical names to scheduler-specific CLI switches.
+ A mapping from canonical names to scheduler-specific CLI switches.
"""
@property
@abstractmethod
def _prefix(self) -> str:
"""
- Returns the scheduler's resource-request prefix.
+ The scheduler's resource-request prefix.
"""
@property
def _processed_props(self) -> dict[str, Any]:
"""
- Pre-process directives before converting to runscript.
+ Pre-processed runscript directives.
"""
return self._props
@@ -124,7 +124,7 @@ def _processed_props(self) -> dict[str, Any]:
@abstractmethod
def _submit_cmd(self) -> str:
"""
- Returns the scheduler's job-submit executable name.
+ The scheduler's job-submit executable name.
"""
def _validate_props(self) -> None:
@@ -149,21 +149,21 @@ class LSF(JobScheduler):
@property
def _directive_separator(self) -> str:
"""
- Returns the character used to separate directive keys and values.
+ The character used to separate directive keys and values.
"""
return " "
@property
def _forbidden_directives(self) -> list[str]:
"""
- Returns directives that this scheduler does not support.
+ Directives that this scheduler does not support.
"""
return []
@property
def _managed_directives(self) -> dict[str, Any]:
"""
- Returns a mapping from canonical names to scheduler-specific CLI switches.
+ A mapping from canonical names to scheduler-specific CLI switches.
"""
return {
_DirectivesOptional.JOB_NAME: "-J",
@@ -181,12 +181,15 @@ def _managed_directives(self) -> dict[str, Any]:
@property
def _prefix(self) -> str:
"""
- Returns the scheduler's resource-request prefix.
+ The scheduler's resource-request prefix.
"""
return "#BSUB"
@property
def _processed_props(self) -> dict[str, Any]:
+ """
+ Pre-processed runscript directives.
+ """
props = deepcopy(self._props)
props[_DirectivesOptional.THREADS] = props.get(_DirectivesOptional.THREADS, 1)
return props
@@ -194,7 +197,7 @@ def _processed_props(self) -> dict[str, Any]:
@property
def _submit_cmd(self) -> str:
"""
- Returns the scheduler's job-submit executable name.
+ The scheduler's job-submit executable name.
"""
return "bsub"
@@ -207,21 +210,21 @@ class PBS(JobScheduler):
@property
def _directive_separator(self) -> str:
"""
- Returns the character used to separate directive keys and values.
+ The character used to separate directive keys and values.
"""
return " "
@property
def _forbidden_directives(self) -> list[str]:
"""
- Returns directives that this scheduler does not support.
+ Directives that this scheduler does not support.
"""
return []
@property
def _managed_directives(self) -> dict[str, Any]:
"""
- Returns a mapping from canonical names to scheduler-specific CLI switches.
+ A mapping from canonical names to scheduler-specific CLI switches.
"""
return {
_DirectivesOptional.DEBUG: lambda x: f"-l debug={str(x).lower()}",
@@ -240,7 +243,7 @@ def _managed_directives(self) -> dict[str, Any]:
@staticmethod
def _placement(items: dict[str, Any]) -> dict[str, Any]:
"""
- Placement logic.
+ Return provided items with scheduler-specific replacements.
"""
exclusive = items.get(_DirectivesOptional.EXCLUSIVE)
placement = items.get(_DirectivesOptional.PLACEMENT)
@@ -258,12 +261,15 @@ def _placement(items: dict[str, Any]) -> dict[str, Any]:
@property
def _prefix(self) -> str:
"""
- Returns the scheduler's resource-request prefix.
+ The scheduler's resource-request prefix.
"""
return "#PBS"
@property
def _processed_props(self) -> dict[str, Any]:
+ """
+ Pre-processed runscript directives.
+ """
props = self._props
props.update(self._select(props))
props.update(self._placement(props))
@@ -278,7 +284,7 @@ def _processed_props(self) -> dict[str, Any]:
def _select(self, items: dict[str, Any]) -> dict[str, Any]:
"""
- Select logic.
+ Return provided items with scheduler-specific selections.
"""
select = []
if nodes := items.get(_DirectivesOptional.NODES):
@@ -299,7 +305,7 @@ def _select(self, items: dict[str, Any]) -> dict[str, Any]:
@property
def _submit_cmd(self) -> str:
"""
- Returns the scheduler's job-submit executable name.
+ The scheduler's job-submit executable name.
"""
return "qsub"
@@ -312,14 +318,14 @@ class Slurm(JobScheduler):
@property
def _forbidden_directives(self) -> list[str]:
"""
- Returns directives that this scheduler does not support.
+ Directives that this scheduler does not support.
"""
return [_DirectivesOptional.SHELL]
@property
def _managed_directives(self) -> dict[str, Any]:
"""
- Returns a mapping from canonical names to scheduler-specific CLI switches.
+ A mapping from canonical names to scheduler-specific CLI switches.
"""
return {
_DirectivesOptional.CORES: "--ntasks",
@@ -343,21 +349,21 @@ def _managed_directives(self) -> dict[str, Any]:
@property
def _directive_separator(self) -> str:
"""
- Returns the character used to separate directive keys and values.
+ The character used to separate directive keys and values.
"""
return "="
@property
def _prefix(self) -> str:
"""
- Returns the scheduler's resource-request prefix.
+ The scheduler's resource-request prefix.
"""
return "#SBATCH"
@property
def _submit_cmd(self) -> str:
"""
- Returns the scheduler's job-submit executable name.
+ The scheduler's job-submit executable name.
"""
return "sbatch"
diff --git a/src/uwtools/strings.py b/src/uwtools/strings.py
index 3319eb592..098724d62 100644
--- a/src/uwtools/strings.py
+++ b/src/uwtools/strings.py
@@ -38,14 +38,14 @@ class FORMAT:
@staticmethod
def extensions() -> list[str]:
"""
- Returns recognized filename extensions.
+ Return recognized filename extensions.
"""
return [FORMAT.ini, FORMAT.nml, FORMAT.sh, FORMAT.yaml]
@staticmethod
def formats() -> dict[str, str]:
"""
- Returns the recognized format names.
+ Return the recognized format names.
"""
return {
field.name: str(getattr(FORMAT, field.name))
@@ -86,6 +86,7 @@ class STR:
file2path: str = "file_2_path"
file: str = "file"
filtertopo: str = "filter_topo"
+ fs: str = "fs"
fv3: str = "fv3"
globalequivresol: str = "global_equiv_resol"
graphfile: str = "graph_file"
@@ -99,6 +100,7 @@ class STR:
keyvalpairs: str = "key_eq_val_pairs"
leadtime: str = "leadtime"
link: str = "link"
+ makedirs: str = "makedirs"
makehgrid: str = "make_hgrid"
makesolomosaic: str = "make_solo_mosaic"
mode: str = "mode"
@@ -126,6 +128,7 @@ class STR:
searchpath: str = "search_path"
sfcclimogen: str = "sfc_climo_gen"
shave: str = "shave"
+ showschema: str = "show_schema"
stdout: str = "stdout"
targetdir: str = "target_dir"
task: str = "task"
diff --git a/src/uwtools/tests/api/test_config.py b/src/uwtools/tests/api/test_config.py
index c2cd84e64..803f35747 100644
--- a/src/uwtools/tests/api/test_config.py
+++ b/src/uwtools/tests/api/test_config.py
@@ -5,11 +5,11 @@
from unittest.mock import patch
import yaml
-from pytest import mark
+from pytest import mark, raises
from uwtools.api import config
from uwtools.config.formats.yaml import YAMLConfig
-from uwtools.exceptions import UWConfigError
+from uwtools.exceptions import UWConfigError, UWError
from uwtools.utils.file import FORMAT
@@ -34,11 +34,11 @@ def test_compare():
@mark.parametrize(
"classname,f",
[
- ("_FieldTableConfig", config.get_fieldtable_config),
- ("_INIConfig", config.get_ini_config),
- ("_NMLConfig", config.get_nml_config),
- ("_SHConfig", config.get_sh_config),
- ("_YAMLConfig", config.get_yaml_config),
+ ("FieldTableConfig", config.get_fieldtable_config),
+ ("INIConfig", config.get_ini_config),
+ ("NMLConfig", config.get_nml_config),
+ ("SHConfig", config.get_sh_config),
+ ("YAMLConfig", config.get_yaml_config),
],
)
def test_get_config(classname, f):
@@ -92,6 +92,31 @@ def test_realize_to_dict():
)
+def test_realize_update_config_from_stdin():
+ with raises(UWError) as e:
+ config.realize(input_config={}, output_file="output.yaml", update_format="yaml")
+ assert str(e.value) == "Set stdin_ok=True to permit read from stdin"
+
+
+def test_realize_update_config_none():
+ input_config = {"n": 88}
+ output_file = Path("output.yaml")
+ with patch.object(config, "_realize") as _realize:
+ config.realize(input_config=input_config, output_file=output_file)
+ _realize.assert_called_once_with(
+ input_config=input_config,
+ input_format=None,
+ update_config=None,
+ update_format=None,
+ output_file=output_file,
+ output_format=None,
+ key_path=None,
+ values_needed=False,
+ total=False,
+ dry_run=False,
+ )
+
+
@mark.parametrize("cfg", [{"foo": "bar"}, YAMLConfig(config={})])
def test_validate(cfg):
kwargs: dict = {"schema_file": "schema-file", "config": cfg}
diff --git a/src/uwtools/tests/api/test_driver.py b/src/uwtools/tests/api/test_driver.py
index 1b49a9737..f1dc5a7c8 100644
--- a/src/uwtools/tests/api/test_driver.py
+++ b/src/uwtools/tests/api/test_driver.py
@@ -1,47 +1,9 @@
-# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name
+# pylint: disable=missing-function-docstring
-import datetime as dt
-import logging
-import os
-import sys
-from pathlib import Path
-from types import SimpleNamespace as ns
-from unittest.mock import patch
-
-from pytest import fixture, mark, raises
+from pytest import mark
from uwtools.api import driver as driver_api
from uwtools.drivers import driver as driver_lib
-from uwtools.exceptions import UWError
-from uwtools.logging import log
-from uwtools.tests.support import fixture_path, logged, regex_logged
-
-# Fixtures
-
-
-@fixture
-def args():
- return ns(
- classname="TestDriver",
- config=fixture_path("testdriver.yaml"),
- module=fixture_path("testdriver.py"),
- schema_file=fixture_path("testdriver.jsonschema"),
- task="eighty_eight",
- )
-
-
-@fixture
-def kwargs(args):
- return dict(
- classname=args.classname,
- config=args.config,
- module=args.module,
- schema_file=args.schema_file,
- task=args.task,
- )
-
-
-# Tests
@mark.parametrize(
@@ -59,154 +21,3 @@ def kwargs(args):
)
def test_driver(classname):
assert getattr(driver_api, classname) is getattr(driver_lib, classname)
-
-
-def test__get_driver_module_implicit():
- pass
-
-
-@mark.parametrize("key,val", [("batch", True), ("leadtime", 6)])
-def test_execute_fail_bad_args(caplog, key, kwargs, val):
- kwargs.update({"cycle": dt.datetime.now(), key: val})
- assert driver_api.execute(**kwargs) is False
- assert logged(caplog, f"TestDriver does not accept argument '{key}'")
-
-
-def test_execute_fail_stdin_not_ok(kwargs):
- kwargs["config"] = None
- kwargs["cycle"] = dt.datetime.now()
- kwargs["stdin_ok"] = False
- with raises(UWError) as e:
- driver_api.execute(**kwargs)
- assert str(e.value) == "Set stdin_ok=True to permit read from stdin"
-
-
-@mark.parametrize("remove", ([], ["schema_file"]))
-def test_execute_pass(caplog, kwargs, remove, tmp_path):
- for kwarg in remove:
- del kwargs[kwarg]
- kwargs["cycle"] = dt.datetime.now()
- log.setLevel(logging.DEBUG)
- graph_file = tmp_path / "g.dot"
- graph_code = "DOT code"
- kwargs["graph_file"] = graph_file
- with patch.object(driver_api, "graph", return_value=graph_code):
- assert driver_api.execute(**kwargs) is True
- assert regex_logged(caplog, "Instantiated %s with" % kwargs["classname"])
- with open(graph_file, "r", encoding="utf-8") as f:
- assert f.read().strip() == graph_code
-
-
-def test_execute_fail_cannot_load_driver_class(kwargs):
- kwargs["module"] = "bad_module_name"
- assert driver_api.execute(**kwargs) is False
-
-
-def test_tasks_fail(args, caplog, tmp_path):
- module = tmp_path / "not.py"
- tasks = driver_api.tasks(classname=args.classname, module=module)
- assert tasks == {}
- assert logged(
- caplog, "Could not get tasks from class %s in module %s" % (args.classname, module)
- )
-
-
-def test_tasks_fail_no_cycle(args, caplog, kwargs):
- log.setLevel(logging.DEBUG)
- assert driver_api.execute(**kwargs) is False
- assert logged(caplog, "%s requires argument '%s'" % (args.classname, "cycle"))
-
-
-@mark.parametrize("f", [Path, str])
-def test_tasks_pass(args, f):
- tasks = driver_api.tasks(classname=args.classname, module=f(args.module))
- assert tasks["eighty_eight"] == "88"
-
-
-def test__get_driver_class_explicit_fail_bad_class(caplog, args):
- log.setLevel(logging.DEBUG)
- bad_class = "BadClass"
- c, module_path = driver_api._get_driver_class(classname=bad_class, module=args.module)
- assert c is None
- assert module_path == args.module
- assert logged(caplog, "Module %s has no class %s" % (args.module, bad_class))
-
-
-def test__get_driver_class_explicit_fail_bad_name(caplog, args):
- log.setLevel(logging.DEBUG)
- bad_name = Path("bad_name")
- c, module_path = driver_api._get_driver_class(classname=args.classname, module=bad_name)
- assert c is None
- assert module_path is None
- assert logged(caplog, "Could not load module %s" % bad_name)
-
-
-def test__get_driver_class_explicit_fail_bad_path(caplog, args, tmp_path):
- log.setLevel(logging.DEBUG)
- module = tmp_path / "not.py"
- c, module_path = driver_api._get_driver_class(classname=args.classname, module=module)
- assert c is None
- assert module_path is None
- assert logged(caplog, "Could not load module %s" % module)
-
-
-def test__get_driver_class_explicit_fail_bad_spec(caplog, args):
- log.setLevel(logging.DEBUG)
- with patch.object(driver_api, "spec_from_file_location", return_value=None):
- c, module_path = driver_api._get_driver_class(classname=args.classname, module=args.module)
- assert c is None
- assert module_path is None
- assert logged(caplog, "Could not load module %s" % args.module)
-
-
-def test__get_driver_class_explicit_pass(args):
- log.setLevel(logging.DEBUG)
- c, module_path = driver_api._get_driver_class(classname=args.classname, module=args.module)
- assert c
- assert c.__name__ == "TestDriver"
- assert module_path == args.module
-
-
-def test__get_driver_class_implicit_pass(args):
- log.setLevel(logging.DEBUG)
- with patch.object(Path, "cwd", return_value=fixture_path()):
- c, module_path = driver_api._get_driver_class(classname=args.classname, module=args.module)
- assert c
- assert c.__name__ == "TestDriver"
- assert module_path == args.module
-
-
-def test__get_driver_module_explicit_absolute_fail(args):
- assert args.module.is_absolute()
- module = args.module.with_suffix(".bad")
- assert not driver_api._get_driver_module_explicit(module=module)
-
-
-def test__get_driver_module_explicit_absolute_pass(args):
- assert args.module.is_absolute()
- assert driver_api._get_driver_module_explicit(module=args.module)
-
-
-def test__get_driver_module_explicit_relative_fail(args):
- args.module = Path(os.path.relpath(args.module)).with_suffix(".bad")
- assert not args.module.is_absolute()
- assert not driver_api._get_driver_module_explicit(module=args.module)
-
-
-def test__get_driver_module_explicit_relative_pass(args):
- args.module = Path(os.path.relpath(args.module))
- assert not args.module.is_absolute()
- assert driver_api._get_driver_module_explicit(module=args.module)
-
-
-def test__get_driver_module_implicit_pass_full_package():
- assert driver_api._get_driver_module_implicit("uwtools.tests.fixtures.testdriver")
-
-
-def test__get_driver_module_implicit_pass():
- with patch.object(sys, "path", [str(fixture_path()), *sys.path]):
- assert driver_api._get_driver_module_implicit("testdriver")
-
-
-def test__get_driver_module_implicit_fail():
- assert not driver_api._get_driver_module_implicit("no.such.module")
diff --git a/src/uwtools/tests/api/test_drivers.py b/src/uwtools/tests/api/test_drivers.py
index 3b957f7ad..70bf280b3 100644
--- a/src/uwtools/tests/api/test_drivers.py
+++ b/src/uwtools/tests/api/test_drivers.py
@@ -10,8 +10,10 @@
cdeps,
chgres_cube,
esg_grid,
+ filter_topo,
fv3,
global_equiv_resol,
+ ioda,
jedi,
make_hgrid,
make_solo_mosaic,
@@ -32,8 +34,10 @@
cdeps,
chgres_cube,
esg_grid,
+ filter_topo,
fv3,
global_equiv_resol,
+ ioda,
jedi,
make_hgrid,
make_solo_mosaic,
@@ -47,7 +51,7 @@
upp,
ww3,
]
-with_cycle = [cdeps, chgres_cube, fv3, jedi, mpas, mpas_init, schism, ungrib, upp, ww3]
+with_cycle = [cdeps, chgres_cube, fv3, ioda, jedi, mpas, mpas_init, schism, ungrib, upp, ww3]
with_leadtime = [upp]
@@ -82,6 +86,11 @@ def test_api_graph(module):
assert module.graph is support.graph
+@mark.parametrize("module", modules)
+def test_api_schema(module):
+ assert module.schema()
+
+
@mark.parametrize("module", modules)
def test_api_tasks(module):
with patch.object(iotaa, "tasknames") as tasknames:
diff --git a/src/uwtools/tests/api/test_execute.py b/src/uwtools/tests/api/test_execute.py
new file mode 100644
index 000000000..b81b39c76
--- /dev/null
+++ b/src/uwtools/tests/api/test_execute.py
@@ -0,0 +1,190 @@
+# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name
+
+import datetime as dt
+import logging
+import os
+import sys
+from pathlib import Path
+from types import SimpleNamespace as ns
+from unittest.mock import patch
+
+from pytest import fixture, mark, raises
+
+from uwtools.api import execute
+from uwtools.exceptions import UWError
+from uwtools.logging import log
+from uwtools.tests.support import fixture_path, logged, regex_logged
+
+# Fixtures
+
+
+@fixture
+def args():
+ return ns(
+ classname="TestDriver",
+ config=fixture_path("testdriver.yaml"),
+ module=fixture_path("testdriver.py"),
+ schema_file=fixture_path("testdriver.jsonschema"),
+ task="eighty_eight",
+ )
+
+
+@fixture
+def kwargs(args):
+ return dict(
+ classname=args.classname,
+ config=args.config,
+ module=args.module,
+ schema_file=args.schema_file,
+ task=args.task,
+ )
+
+
+# Tests
+
+
+@mark.parametrize("key,val", [("batch", True), ("leadtime", 6)])
+def test_execute_fail_bad_args(caplog, key, kwargs, val):
+ kwargs.update({"cycle": dt.datetime.now(), key: val})
+ assert execute.execute(**kwargs) is False
+ assert logged(caplog, f"TestDriver does not accept argument '{key}'")
+
+
+def test_execute_fail_stdin_not_ok(kwargs):
+ kwargs["config"] = None
+ kwargs["cycle"] = dt.datetime.now()
+ kwargs["stdin_ok"] = False
+ with raises(UWError) as e:
+ execute.execute(**kwargs)
+ assert str(e.value) == "Set stdin_ok=True to permit read from stdin"
+
+
+@mark.parametrize("remove", ([], ["schema_file"]))
+def test_execute_pass(caplog, kwargs, remove, tmp_path):
+ for kwarg in remove:
+ del kwargs[kwarg]
+ kwargs["cycle"] = dt.datetime.now()
+ log.setLevel(logging.DEBUG)
+ graph_file = tmp_path / "g.dot"
+ graph_code = "DOT code"
+ kwargs["graph_file"] = graph_file
+ with patch.object(execute, "graph", return_value=graph_code):
+ assert execute.execute(**kwargs) is True
+ assert regex_logged(caplog, "Instantiated %s with" % kwargs["classname"])
+ with open(graph_file, "r", encoding="utf-8") as f:
+ assert f.read().strip() == graph_code
+
+
+def test_execute_fail_cannot_load_driver_class(kwargs):
+ kwargs["module"] = "bad_module_name"
+ assert execute.execute(**kwargs) is False
+
+
+def test_tasks_fail(args, caplog, tmp_path):
+ module = tmp_path / "not.py"
+ tasks = execute.tasks(classname=args.classname, module=module)
+ assert tasks == {}
+ assert logged(
+ caplog, "Could not get tasks from class %s in module %s" % (args.classname, module)
+ )
+
+
+def test_tasks_fail_no_cycle(args, caplog, kwargs):
+ log.setLevel(logging.DEBUG)
+ assert execute.execute(**kwargs) is False
+ assert logged(caplog, "%s requires argument '%s'" % (args.classname, "cycle"))
+
+
+@mark.parametrize("f", [Path, str])
+def test_tasks_pass(args, f):
+ tasks = execute.tasks(classname=args.classname, module=f(args.module))
+ assert tasks["eighty_eight"] == "88"
+
+
+def test__get_driver_class_explicit_fail_bad_class(caplog, args):
+ log.setLevel(logging.DEBUG)
+ bad_class = "BadClass"
+ c, module_path = execute._get_driver_class(classname=bad_class, module=args.module)
+ assert c is None
+ assert module_path == args.module
+ assert logged(caplog, "Module %s has no class %s" % (args.module, bad_class))
+
+
+def test__get_driver_class_explicit_fail_bad_name(caplog, args):
+ log.setLevel(logging.DEBUG)
+ bad_name = Path("bad_name")
+ c, module_path = execute._get_driver_class(classname=args.classname, module=bad_name)
+ assert c is None
+ assert module_path is None
+ assert logged(caplog, "Could not load module %s" % bad_name)
+
+
+def test__get_driver_class_explicit_fail_bad_path(caplog, args, tmp_path):
+ log.setLevel(logging.DEBUG)
+ module = tmp_path / "not.py"
+ c, module_path = execute._get_driver_class(classname=args.classname, module=module)
+ assert c is None
+ assert module_path is None
+ assert logged(caplog, "Could not load module %s" % module)
+
+
+def test__get_driver_class_explicit_fail_bad_spec(caplog, args):
+ log.setLevel(logging.DEBUG)
+ with patch.object(execute, "spec_from_file_location", return_value=None):
+ c, module_path = execute._get_driver_class(classname=args.classname, module=args.module)
+ assert c is None
+ assert module_path is None
+ assert logged(caplog, "Could not load module %s" % args.module)
+
+
+def test__get_driver_class_explicit_pass(args):
+ log.setLevel(logging.DEBUG)
+ c, module_path = execute._get_driver_class(classname=args.classname, module=args.module)
+ assert c
+ assert c.__name__ == "TestDriver"
+ assert module_path == args.module
+
+
+def test__get_driver_class_implicit_pass(args):
+ log.setLevel(logging.DEBUG)
+ with patch.object(Path, "cwd", return_value=fixture_path()):
+ c, module_path = execute._get_driver_class(classname=args.classname, module=args.module)
+ assert c
+ assert c.__name__ == "TestDriver"
+ assert module_path == args.module
+
+
+def test__get_driver_module_explicit_absolute_fail(args):
+ assert args.module.is_absolute()
+ module = args.module.with_suffix(".bad")
+ assert not execute._get_driver_module_explicit(module=module)
+
+
+def test__get_driver_module_explicit_absolute_pass(args):
+ assert args.module.is_absolute()
+ assert execute._get_driver_module_explicit(module=args.module)
+
+
+def test__get_driver_module_explicit_relative_fail(args):
+ args.module = Path(os.path.relpath(args.module)).with_suffix(".bad")
+ assert not args.module.is_absolute()
+ assert not execute._get_driver_module_explicit(module=args.module)
+
+
+def test__get_driver_module_explicit_relative_pass(args):
+ args.module = Path(os.path.relpath(args.module))
+ assert not args.module.is_absolute()
+ assert execute._get_driver_module_explicit(module=args.module)
+
+
+def test__get_driver_module_implicit_pass_full_package():
+ assert execute._get_driver_module_implicit("uwtools.tests.fixtures.testdriver")
+
+
+def test__get_driver_module_implicit_pass():
+ with patch.object(sys, "path", [str(fixture_path()), *sys.path]):
+ assert execute._get_driver_module_implicit("testdriver")
+
+
+def test__get_driver_module_implicit_fail():
+ assert not execute._get_driver_module_implicit("no.such.module")
diff --git a/src/uwtools/tests/api/test_file.py b/src/uwtools/tests/api/test_fs.py
similarity index 77%
rename from src/uwtools/tests/api/test_file.py
rename to src/uwtools/tests/api/test_fs.py
index a403bb99b..71d48b5cb 100644
--- a/src/uwtools/tests/api/test_file.py
+++ b/src/uwtools/tests/api/test_fs.py
@@ -5,7 +5,7 @@
from pytest import fixture
-from uwtools.api import file
+from uwtools.api import fs
@fixture
@@ -31,7 +31,7 @@ def test_copy_fail(kwargs):
for p in paths:
assert not Path(p).exists()
Path(list(paths.values())[0]).unlink()
- assert file.copy(**kwargs) is False
+ assert fs.copy(**kwargs) is False
assert not Path(list(paths.keys())[0]).exists()
assert Path(list(paths.keys())[1]).is_file()
@@ -40,7 +40,7 @@ def test_copy_pass(kwargs):
paths = kwargs["config"]["a"]["b"]
for p in paths:
assert not Path(p).exists()
- assert file.copy(**kwargs) is True
+ assert fs.copy(**kwargs) is True
for p in paths:
assert Path(p).is_file()
@@ -50,7 +50,7 @@ def test_link_fail(kwargs):
for p in paths:
assert not Path(p).exists()
Path(list(paths.values())[0]).unlink()
- assert file.link(**kwargs) is False
+ assert fs.link(**kwargs) is False
assert not Path(list(paths.keys())[0]).exists()
assert Path(list(paths.keys())[1]).is_symlink()
@@ -59,6 +59,13 @@ def test_link_pass(kwargs):
paths = kwargs["config"]["a"]["b"]
for p in paths:
assert not Path(p).exists()
- assert file.link(**kwargs) is True
+ assert fs.link(**kwargs) is True
for p in paths:
assert Path(p).is_symlink()
+
+
+def test_makedirs(tmp_path):
+ paths = [tmp_path / "foo" / x for x in ("bar", "baz")]
+ assert not any(path.is_dir() for path in paths)
+ assert fs.makedirs(config={"makedirs": [str(path) for path in paths]}) is True
+ assert all(path.is_dir() for path in paths)
diff --git a/src/uwtools/tests/config/formats/test_base.py b/src/uwtools/tests/config/formats/test_base.py
index fa2039b09..a4ba27c87 100644
--- a/src/uwtools/tests/config/formats/test_base.py
+++ b/src/uwtools/tests/config/formats/test_base.py
@@ -43,6 +43,14 @@ class ConcreteConfig(Config):
def _dict_to_str(cls, cfg):
pass
+ @staticmethod
+ def _get_depth_threshold():
+ pass
+
+ @staticmethod
+ def _get_format():
+ pass
+
def _load(self, config_file):
with readable(config_file) as f:
return yaml.safe_load(f.read())
@@ -54,16 +62,19 @@ def dump(self, path=None):
def dump_dict(cfg, path=None):
pass
- @staticmethod
- def get_depth_threshold():
- pass
- @staticmethod
- def get_format():
- pass
+# Tests
-# Tests
+def test__characterize_values(config):
+ values = {1: "", 2: None, 3: "{{ n }}", 4: {"a": 88}, 5: [{"b": 99}], 6: "string"}
+ complete, template = config._characterize_values(values=values, parent="p")
+ assert complete == [" p1", " p2", " p4", " p4.a", " pb", " p5", " p6"]
+ assert template == [" p3: {{ n }}"]
+
+
+def test__depth(config):
+ assert config._depth == 1
def test__load_paths(config, tmp_path):
@@ -76,11 +87,28 @@ def test__load_paths(config, tmp_path):
assert cfg[path.name] == "present"
-def test_characterize_values(config):
- values = {1: "", 2: None, 3: "{{ n }}", 4: {"a": 88}, 5: [{"b": 99}], 6: "string"}
- complete, template = config.characterize_values(values=values, parent="p")
- assert complete == [" p1", " p2", " p4", " p4.a", " pb", " p5", " p6"]
- assert template == [" p3: {{ n }}"]
+def test__parse_include(config):
+ """
+ Test that non-YAML handles include tags properly.
+ """
+ del config["foo"]
+ # Create a symlink for the include file:
+ include_path = fixture_path("fruit_config.yaml")
+ config.data.update(
+ {
+ "config": {
+ "salad_include": f"!INCLUDE [{include_path}]",
+ "meat": "beef",
+ "dressing": "poppyseed",
+ }
+ }
+ )
+ config._parse_include()
+
+ assert config["fruit"] == "papaya"
+ assert config["how_many"] == 17
+ assert config["config"]["meat"] == "beef"
+ assert len(config["config"]) == 2
@mark.parametrize("fmt", [FORMAT.ini, FORMAT.nml, FORMAT.yaml])
@@ -111,10 +139,6 @@ def test_compare_config(caplog, fmt, salad_base):
assert logged(caplog, msg)
-def test_depth(config):
- assert config.depth == 1
-
-
def test_dereference(tmp_path):
# Test demonstrates that:
# - Config dereferencing ignores environment variables.
@@ -177,31 +201,7 @@ def test_invalid_config(fmt2, tmp_path):
assert f"Cannot dump depth-{depthin} config to type-'{fmt2}' config" in str(e.value)
-def test_parse_include(config):
- """
- Test that non-YAML handles include tags properly.
- """
- del config["foo"]
- # Create a symlink for the include file:
- include_path = fixture_path("fruit_config.yaml")
- config.data.update(
- {
- "config": {
- "salad_include": f"!INCLUDE [{include_path}]",
- "meat": "beef",
- "dressing": "poppyseed",
- }
- }
- )
- config.parse_include()
-
- assert config["fruit"] == "papaya"
- assert config["how_many"] == 17
- assert config["config"]["meat"] == "beef"
- assert len(config["config"]) == 2
-
-
-def test_update_values(config):
+def test_update_from(config):
"""
Test that a config object can be updated.
"""
diff --git a/src/uwtools/tests/config/formats/test_fieldtable.py b/src/uwtools/tests/config/formats/test_fieldtable.py
index edcfe9f5f..8776f84b5 100644
--- a/src/uwtools/tests/config/formats/test_fieldtable.py
+++ b/src/uwtools/tests/config/formats/test_fieldtable.py
@@ -1,4 +1,4 @@
-# pylint: disable=missing-function-docstring,redefined-outer-name
+# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name
"""
Tests for uwtools.config.formats.fieldtable module.
"""
@@ -9,7 +9,7 @@
from uwtools.tests.support import fixture_path
from uwtools.utils.file import FORMAT
-# Tests
+# Fixtures
@fixture(scope="module")
@@ -23,12 +23,15 @@ def ref():
return f.read().strip()
-def test_fieldtable_get_format():
- assert FieldTableConfig.get_format() == FORMAT.fieldtable
+# Tests
+
+
+def test_fieldtable__get_depth_threshold():
+ assert FieldTableConfig._get_depth_threshold() is None
-def test_fieldtable_get_depth_threshold():
- assert FieldTableConfig.get_depth_threshold() is None
+def test_fieldtable__get_format():
+ assert FieldTableConfig._get_format() == FORMAT.fieldtable
def test_fieldtable_instantiation_depth():
diff --git a/src/uwtools/tests/config/formats/test_ini.py b/src/uwtools/tests/config/formats/test_ini.py
index d12bdbd11..c6a9f9f7f 100644
--- a/src/uwtools/tests/config/formats/test_ini.py
+++ b/src/uwtools/tests/config/formats/test_ini.py
@@ -1,4 +1,4 @@
-# pylint: disable=missing-function-docstring
+# pylint: disable=missing-function-docstring,protected-access
"""
Tests for uwtools.config.formats.ini module.
"""
@@ -15,21 +15,15 @@
# Tests
-def test_ini_get_format():
- assert INIConfig.get_format() == FORMAT.ini
+def test_ini__get_depth_threshold():
+ assert INIConfig._get_depth_threshold() == 2
-def test_ini_get_depth_threshold():
- assert INIConfig.get_depth_threshold() == 2
+def test_ini__get_format():
+ assert INIConfig._get_format() == FORMAT.ini
-def test_ini_instantiation_depth():
- with raises(UWConfigError) as e:
- INIConfig(config={1: {2: {3: 4}}})
- assert str(e.value) == "Cannot instantiate depth-2 INIConfig with depth-3 config"
-
-
-def test_ini_parse_include():
+def test_ini__parse_include():
"""
Test that an INI file handles include tags properly.
"""
@@ -40,6 +34,12 @@ def test_ini_parse_include():
assert len(cfgobj["config"]) == 5
+def test_ini_instantiation_depth():
+ with raises(UWConfigError) as e:
+ INIConfig(config={1: {2: {3: 4}}})
+ assert str(e.value) == "Cannot instantiate depth-2 INIConfig with depth-3 config"
+
+
@mark.parametrize("func", [repr, str])
def test_ini_repr_str(func):
config = fixture_path("simple.ini")
diff --git a/src/uwtools/tests/config/formats/test_nml.py b/src/uwtools/tests/config/formats/test_nml.py
index 59c817954..c91c16dde 100644
--- a/src/uwtools/tests/config/formats/test_nml.py
+++ b/src/uwtools/tests/config/formats/test_nml.py
@@ -1,4 +1,4 @@
-# pylint: disable=missing-function-docstring,redefined-outer-name
+# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name
"""
Tests for uwtools.config.formats.nml module.
"""
@@ -24,6 +24,39 @@ def data():
# Tests
+def test_nml__get_depth_threshold():
+ assert NMLConfig._get_depth_threshold() is None
+
+
+def test_nml__get_format():
+ assert NMLConfig._get_format() == FORMAT.nml
+
+
+def test_nml__parse_include():
+ """
+ Test that non-YAML handles include tags properly.
+ """
+ cfgobj = NMLConfig(fixture_path("include_files.nml"))
+ assert cfgobj["config"]["fruit"] == "papaya"
+ assert cfgobj["config"]["how_many"] == 17
+ assert cfgobj["config"]["meat"] == "beef"
+ assert len(cfgobj["config"]) == 5
+
+
+def test_nml__parse_include_mult_sect():
+ """
+ Test that non-YAML handles include tags with files that have multiple sections in separate file.
+ """
+ cfgobj = NMLConfig(fixture_path("include_files_with_sect.nml"))
+ assert cfgobj["config"]["fruit"] == "papaya"
+ assert cfgobj["config"]["how_many"] == 17
+ assert cfgobj["config"]["meat"] == "beef"
+ assert cfgobj["config"]["dressing"] == "ranch"
+ assert cfgobj["setting"]["size"] == "large"
+ assert len(cfgobj["config"]) == 5
+ assert len(cfgobj["setting"]) == 3
+
+
def test_nml_derived_type_dict():
nml = NMLConfig(config={"nl": {"o": {"i": 77, "j": 88}}})
assert nml["nl"]["o"] == {"i": 77, "j": 88}
@@ -57,39 +90,6 @@ def test_nml_dump_dict_Namelist(data, tmp_path):
assert nml == data
-def test_nml_get_format():
- assert NMLConfig.get_format() == FORMAT.nml
-
-
-def test_nml_get_depth_threshold():
- assert NMLConfig.get_depth_threshold() is None
-
-
-def test_nml_parse_include():
- """
- Test that non-YAML handles include tags properly.
- """
- cfgobj = NMLConfig(fixture_path("include_files.nml"))
- assert cfgobj["config"]["fruit"] == "papaya"
- assert cfgobj["config"]["how_many"] == 17
- assert cfgobj["config"]["meat"] == "beef"
- assert len(cfgobj["config"]) == 5
-
-
-def test_nml_parse_include_mult_sect():
- """
- Test that non-YAML handles include tags with files that have multiple sections in separate file.
- """
- cfgobj = NMLConfig(fixture_path("include_files_with_sect.nml"))
- assert cfgobj["config"]["fruit"] == "papaya"
- assert cfgobj["config"]["how_many"] == 17
- assert cfgobj["config"]["meat"] == "beef"
- assert cfgobj["config"]["dressing"] == "ranch"
- assert cfgobj["setting"]["size"] == "large"
- assert len(cfgobj["config"]) == 5
- assert len(cfgobj["setting"]) == 3
-
-
@mark.parametrize("func", [repr, str])
def test_ini_repr_str(func):
config = fixture_path("simple.nml")
diff --git a/src/uwtools/tests/config/formats/test_sh.py b/src/uwtools/tests/config/formats/test_sh.py
index 5d51a043e..ce6a0119e 100644
--- a/src/uwtools/tests/config/formats/test_sh.py
+++ b/src/uwtools/tests/config/formats/test_sh.py
@@ -1,4 +1,4 @@
-# pylint: disable=missing-function-docstring
+# pylint: disable=missing-function-docstring,protected-access
"""
Tests for uwtools.config.formats.sh module.
"""
@@ -16,21 +16,15 @@
# Tests
-def test_sh_get_format():
- assert SHConfig.get_format() == FORMAT.sh
+def test_sh__get_depth_threshold():
+ assert SHConfig._get_depth_threshold() == 1
-def test_sh_get_depth_threshold():
- assert SHConfig.get_depth_threshold() == 1
+def test_sh__get_format():
+ assert SHConfig._get_format() == FORMAT.sh
-def test_sh_instantiation_depth():
- with raises(UWConfigError) as e:
- SHConfig(config={1: {2: {3: 4}}})
- assert str(e.value) == "Cannot instantiate depth-1 SHConfig with depth-3 config"
-
-
-def test_sh_parse_include():
+def test_sh__parse_include():
"""
Test that an sh file with no sections handles include tags properly.
"""
@@ -41,6 +35,12 @@ def test_sh_parse_include():
assert len(cfgobj) == 5
+def test_sh_instantiation_depth():
+ with raises(UWConfigError) as e:
+ SHConfig(config={1: {2: {3: 4}}})
+ assert str(e.value) == "Cannot instantiate depth-1 SHConfig with depth-3 config"
+
+
@mark.parametrize("func", [repr, str])
def test_sh_repr_str(func):
config = fixture_path("simple.sh")
diff --git a/src/uwtools/tests/config/formats/test_yaml.py b/src/uwtools/tests/config/formats/test_yaml.py
index 415785c2f..98a374d59 100644
--- a/src/uwtools/tests/config/formats/test_yaml.py
+++ b/src/uwtools/tests/config/formats/test_yaml.py
@@ -27,12 +27,34 @@
# Tests
-def test_yaml_get_format():
- assert YAMLConfig.get_format() == FORMAT.yaml
+def test_yaml__add_yaml_representers():
+ YAMLConfig._add_yaml_representers()
+ representers = yaml.Dumper.yaml_representers
+ assert support.UWYAMLConvert in representers
+ assert OrderedDict in representers
+ assert f90nml.Namelist in representers
+
+
+def test_yaml__get_depth_threshold():
+ assert YAMLConfig._get_depth_threshold() is None
+
+
+def test_yaml__get_format():
+ assert YAMLConfig._get_format() == FORMAT.yaml
+
+
+def test_yaml__represent_namelist():
+ YAMLConfig._add_yaml_representers()
+ namelist = f90nml.reads("&namelist\n key = value\n/\n")
+ expected = "{namelist: {key: value}}"
+ assert yaml.dump(namelist, default_flow_style=True).strip() == expected
-def test_yaml_get_depth_threshold():
- assert YAMLConfig.get_depth_threshold() is None
+def test_yaml__represent_ordereddict():
+ YAMLConfig._add_yaml_representers()
+ ordereddict_values = OrderedDict([("example", OrderedDict([("key", "value")]))])
+ expected = "{example: {key: value}}"
+ assert yaml.dump(ordereddict_values, default_flow_style=True).strip() == expected
def test_yaml_instantiation_depth():
@@ -133,10 +155,10 @@ def test_yaml_constructor_error_not_dict_from_file(tmp_path):
def test_yaml_constructor_error_not_dict_from_stdin():
# Test that a useful exception is raised if the YAML stdin input is a non-dict value.
- with StringIO("a string") as sio, patch.object(sys, "stdin", new=sio):
+ with StringIO("88") as sio, patch.object(sys, "stdin", new=sio):
with raises(exceptions.UWConfigError) as e:
YAMLConfig()
- assert "Parsed a str value from stdin, expected a dict" in str(e.value)
+ assert "Parsed an int value from stdin, expected a dict" in str(e.value)
def test_yaml_constructor_error_unregistered_constructor(tmp_path):
@@ -186,25 +208,3 @@ def test_yaml_unexpected_error(tmp_path):
with raises(UWConfigError) as e:
YAMLConfig(config=cfgfile)
assert msg in str(e.value)
-
-
-def test_yaml__add_yaml_representers():
- YAMLConfig._add_yaml_representers()
- representers = yaml.Dumper.yaml_representers
- assert support.UWYAMLConvert in representers
- assert OrderedDict in representers
- assert f90nml.Namelist in representers
-
-
-def test_yaml__represent_namelist():
- YAMLConfig._add_yaml_representers()
- namelist = f90nml.reads("&namelist\n key = value\n/\n")
- expected = "{namelist: {key: value}}"
- assert yaml.dump(namelist, default_flow_style=True).strip() == expected
-
-
-def test_yaml__represent_ordereddict():
- YAMLConfig._add_yaml_representers()
- ordereddict_values = OrderedDict([("example", OrderedDict([("key", "value")]))])
- expected = "{example: {key: value}}"
- assert yaml.dump(ordereddict_values, default_flow_style=True).strip() == expected
diff --git a/src/uwtools/tests/config/test_tools.py b/src/uwtools/tests/config/test_tools.py
index 2964dbe30..c80be3d6d 100644
--- a/src/uwtools/tests/config/test_tools.py
+++ b/src/uwtools/tests/config/test_tools.py
@@ -74,7 +74,7 @@ def help_realize_config_fmt2fmt(input_file, input_format, update_file, update_fo
)
cfgclass = tools.format_to_config(input_format)
cfgobj = cfgclass(input_file)
- cfgobj.update_values(cfgclass(update_file))
+ cfgobj.update_from(cfgclass(update_file))
reference = tmpdir / f"expected{ext}"
cfgobj.dump(reference)
assert compare_files(reference, output_file)
diff --git a/src/uwtools/tests/config/test_validator.py b/src/uwtools/tests/config/test_validator.py
index 098cfe284..983c7abf0 100644
--- a/src/uwtools/tests/config/test_validator.py
+++ b/src/uwtools/tests/config/test_validator.py
@@ -6,7 +6,7 @@
import logging
from pathlib import Path
from typing import Any
-from unittest.mock import patch
+from unittest.mock import Mock, patch
import yaml
from pytest import fixture, raises
@@ -124,6 +124,21 @@ def write_as_json(data: dict[str, Any], path: Path) -> Path:
# Test functions
+def test_bundle():
+ schema = {"fruit": {"$ref": "urn:uwtools:a"}, "flowers": None}
+ with patch.object(validator, "_registry") as _registry:
+ outer, inner = Mock(), Mock()
+ outer.value.contents = {"a": {"$ref": "urn:uwtools:attrs"}, "b": {"name": "banana"}}
+ inner.value.contents = {"name": "apple"}
+ _registry().get_or_retrieve.side_effect = [outer, inner]
+ bundled = validator.bundle(schema)
+ assert bundled == {"fruit": {"a": {"name": "apple"}, "b": {"name": "banana"}}, "flowers": None}
+ assert [_registry().get_or_retrieve.mock_calls[i].args[0] for i in (0, 1)] == [
+ "urn:uwtools:a",
+ "urn:uwtools:attrs",
+ ]
+
+
def test_get_schema_file():
with patch.object(validator, "resource_path", return_value=Path("/foo/bar")):
assert validator.get_schema_file("baz") == Path("/foo/bar/baz.jsonschema")
@@ -191,6 +206,18 @@ def test__prep_config_file(prep_config_dict, tmp_path):
assert cfgobj == {"roses": "red", "color": "red"}
+def test__registry(tmp_path):
+ validator._registry.cache_clear()
+ d = {"foo": "bar"}
+ path = tmp_path / "foo-bar.jsonschema"
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(d, f)
+ with patch.object(validator, "resource_path", return_value=path) as resource_path:
+ r = validator._registry()
+ assert r.get_or_retrieve("urn:uwtools:foo-bar").value.contents == d
+ resource_path.assert_called_once_with("jsonschema/foo-bar.jsonschema")
+
+
def test__validation_errors_bad_enum_value(config, schema):
config["color"] = "yellow"
assert len(validator._validation_errors(config, schema)) == 1
diff --git a/src/uwtools/tests/drivers/test_cdeps.py b/src/uwtools/tests/drivers/test_cdeps.py
index e4ecfd5a6..66ad90867 100644
--- a/src/uwtools/tests/drivers/test_cdeps.py
+++ b/src/uwtools/tests/drivers/test_cdeps.py
@@ -112,7 +112,7 @@ def test_CDEPS_streams(driverobj, group):
def test_CDEPS_driver_name(driverobj):
- assert driverobj.driver_name == "cdeps"
+ assert driverobj.driver_name() == CDEPS.driver_name() == "cdeps"
def test_CDEPS__model_namelist_file(driverobj):
diff --git a/src/uwtools/tests/drivers/test_chgres_cube.py b/src/uwtools/tests/drivers/test_chgres_cube.py
index 66ae79d4f..de399e7fe 100644
--- a/src/uwtools/tests/drivers/test_chgres_cube.py
+++ b/src/uwtools/tests/drivers/test_chgres_cube.py
@@ -148,7 +148,7 @@ def test_ChgresCube_runscript(driverobj):
def test_ChgresCube_driver_name(driverobj):
- assert driverobj.driver_name == "chgres_cube"
+ assert driverobj.driver_name() == ChgresCube.driver_name() == "chgres_cube"
def test_ChgresCube_taskname(driverobj):
diff --git a/src/uwtools/tests/drivers/test_driver.py b/src/uwtools/tests/drivers/test_driver.py
index 4b1a74f91..9c80c7769 100644
--- a/src/uwtools/tests/drivers/test_driver.py
+++ b/src/uwtools/tests/drivers/test_driver.py
@@ -39,8 +39,8 @@ def atask(self):
def provisioned_rundir(self):
pass
- @property
- def driver_name(self) -> str:
+ @classmethod
+ def driver_name(cls) -> str:
return "concrete"
def _validate(self, schema_file: Optional[Path] = None) -> None:
@@ -218,7 +218,7 @@ def test_Assets_key_path(config, tmp_path):
assetsobj = ConcreteAssetsTimeInvariant(
config=config_file, dry_run=False, key_path=["foo", "bar"]
)
- assert assetsobj.config == config[assetsobj.driver_name]
+ assert assetsobj.config == config[assetsobj.driver_name()]
def test_Assets_leadtime(config):
@@ -273,6 +273,11 @@ def test_Assets__rundir(assetsobj):
assert assetsobj.rundir == Path(assetsobj.config["rundir"])
+def test_Assets__schema_name():
+ with patch.object(driver.Assets, "driver_name", return_value="a_driver"):
+ assert driver.Assets._schema_name() == "a-driver"
+
+
def test_Assets__validate_internal(assetsobj):
with patch.object(assetsobj, "_validate", driver.Assets._validate):
with patch.object(driver, "validate_internal") as validate_internal:
@@ -376,9 +381,9 @@ def test_Driver__run_via_local_execution(driverobj):
executable = Path(driverobj.config["execution"]["executable"])
executable.touch()
with patch.object(driverobj, "provisioned_rundir") as prd:
- with patch.object(driver, "execute") as execute:
+ with patch.object(driver, "run_shell_cmd") as run_shell_cmd:
driverobj._run_via_local_execution()
- execute.assert_called_once_with(
+ run_shell_cmd.assert_called_once_with(
cmd="{x} >{x}.out 2>&1".format(x=driverobj._runscript_path),
cwd=driverobj.rundir,
log_output=True,
diff --git a/src/uwtools/tests/drivers/test_esg_grid.py b/src/uwtools/tests/drivers/test_esg_grid.py
index 838c480a5..950ce2d6d 100644
--- a/src/uwtools/tests/drivers/test_esg_grid.py
+++ b/src/uwtools/tests/drivers/test_esg_grid.py
@@ -124,4 +124,4 @@ def test_ESGGrid_provisioned_rundir(driverobj):
def test_FilterTopo_driver_name(driverobj):
- assert driverobj.driver_name == "esg_grid"
+ assert driverobj.driver_name() == ESGGrid.driver_name() == "esg_grid"
diff --git a/src/uwtools/tests/drivers/test_filter_topo.py b/src/uwtools/tests/drivers/test_filter_topo.py
index e02bb24c3..54b243fdf 100644
--- a/src/uwtools/tests/drivers/test_filter_topo.py
+++ b/src/uwtools/tests/drivers/test_filter_topo.py
@@ -101,4 +101,4 @@ def test_FilterTopo_provisioned_rundir(driverobj):
def test_FilterTopo_driver_name(driverobj):
- assert driverobj.driver_name == "filter_topo"
+ assert driverobj.driver_name() == FilterTopo.driver_name() == "filter_topo"
diff --git a/src/uwtools/tests/drivers/test_fv3.py b/src/uwtools/tests/drivers/test_fv3.py
index c5997c768..ebc2a08bf 100644
--- a/src/uwtools/tests/drivers/test_fv3.py
+++ b/src/uwtools/tests/drivers/test_fv3.py
@@ -248,7 +248,7 @@ def test_FV3_runscript(driverobj):
def test_FV3_driver_name(driverobj):
- assert driverobj.driver_name == "fv3"
+ assert driverobj.driver_name() == FV3.driver_name() == "fv3"
def test_FV3_taskname(driverobj):
diff --git a/src/uwtools/tests/drivers/test_global_equiv_resol.py b/src/uwtools/tests/drivers/test_global_equiv_resol.py
index 678b84bf7..f95201940 100644
--- a/src/uwtools/tests/drivers/test_global_equiv_resol.py
+++ b/src/uwtools/tests/drivers/test_global_equiv_resol.py
@@ -84,7 +84,7 @@ def test_GlobalEquivResol_provisioned_rundir(driverobj):
def test_FilterTopo_driver_name(driverobj):
- assert driverobj.driver_name == "global_equiv_resol"
+ assert driverobj.driver_name() == GlobalEquivResol.driver_name() == "global_equiv_resol"
def test_GlobalEquivResol__runcmd(driverobj):
diff --git a/src/uwtools/tests/drivers/test_ioda.py b/src/uwtools/tests/drivers/test_ioda.py
index 2c0442bba..65527f86c 100644
--- a/src/uwtools/tests/drivers/test_ioda.py
+++ b/src/uwtools/tests/drivers/test_ioda.py
@@ -101,7 +101,7 @@ def test_IODA_provisioned_rundir(driverobj):
def test_IODA_driver_name(driverobj):
- assert driverobj.driver_name == "ioda"
+ assert driverobj.driver_name() == IODA.driver_name() == "ioda"
def test_IODA__config_fn(driverobj):
diff --git a/src/uwtools/tests/drivers/test_jedi.py b/src/uwtools/tests/drivers/test_jedi.py
index 5e3ea8e6e..ebd7ecfcc 100644
--- a/src/uwtools/tests/drivers/test_jedi.py
+++ b/src/uwtools/tests/drivers/test_jedi.py
@@ -189,7 +189,7 @@ def file(path: Path):
def test_JEDI_driver_name(driverobj):
- assert driverobj.driver_name == "jedi"
+ assert driverobj.driver_name() == JEDI.driver_name() == "jedi"
def test_JEDI__config_fn(driverobj):
diff --git a/src/uwtools/tests/drivers/test_make_hgrid.py b/src/uwtools/tests/drivers/test_make_hgrid.py
index 46a24d02e..74874e40d 100644
--- a/src/uwtools/tests/drivers/test_make_hgrid.py
+++ b/src/uwtools/tests/drivers/test_make_hgrid.py
@@ -77,7 +77,7 @@ def test_MakeHgrid_provisioned_rundir(driverobj):
def test_MakeHgrid_driver_name(driverobj):
- assert driverobj.driver_name == "make_hgrid"
+ assert driverobj.driver_name() == MakeHgrid.driver_name() == "make_hgrid"
def test_MakeHgrid__runcmd(driverobj):
diff --git a/src/uwtools/tests/drivers/test_make_solo_mosaic.py b/src/uwtools/tests/drivers/test_make_solo_mosaic.py
index f4fd49339..bdd0f4573 100644
--- a/src/uwtools/tests/drivers/test_make_solo_mosaic.py
+++ b/src/uwtools/tests/drivers/test_make_solo_mosaic.py
@@ -71,7 +71,7 @@ def test_MakeSoloMosaic_provisioned_rundir(driverobj):
def test_MakeSoloMosaic_driver_name(driverobj):
- assert driverobj.driver_name == "make_solo_mosaic"
+ assert driverobj.driver_name() == MakeSoloMosaic.driver_name() == "make_solo_mosaic"
def test_MakeSoloMosaic__runcmd(driverobj):
diff --git a/src/uwtools/tests/drivers/test_mpas.py b/src/uwtools/tests/drivers/test_mpas.py
index e0b252c49..b85a55588 100644
--- a/src/uwtools/tests/drivers/test_mpas.py
+++ b/src/uwtools/tests/drivers/test_mpas.py
@@ -237,7 +237,7 @@ def test_MPAS_provisioned_rundir(driverobj):
def test_MPAS_driver_name(driverobj):
- assert driverobj.driver_name == "mpas"
+ assert driverobj.driver_name() == MPAS.driver_name() == "mpas"
def test_MPAS_streams_file(config, driverobj):
diff --git a/src/uwtools/tests/drivers/test_mpas_init.py b/src/uwtools/tests/drivers/test_mpas_init.py
index 679267d38..f9784755c 100644
--- a/src/uwtools/tests/drivers/test_mpas_init.py
+++ b/src/uwtools/tests/drivers/test_mpas_init.py
@@ -212,7 +212,7 @@ def test_MPASInit_provisioned_rundir(driverobj):
def test_MPASInit_driver_name(driverobj):
- assert driverobj.driver_name == "mpas_init"
+ assert driverobj.driver_name() == MPASInit.driver_name() == "mpas_init"
def test_MPASInit_streams_file(config, driverobj):
diff --git a/src/uwtools/tests/drivers/test_orog_gsl.py b/src/uwtools/tests/drivers/test_orog_gsl.py
index 875f52fe2..a942aa687 100644
--- a/src/uwtools/tests/drivers/test_orog_gsl.py
+++ b/src/uwtools/tests/drivers/test_orog_gsl.py
@@ -103,7 +103,7 @@ def test_OrogGSL_topo_data_3os(driverobj):
def test_OrogGSL_driver_name(driverobj):
- assert driverobj.driver_name == "orog_gsl"
+ assert driverobj.driver_name() == OrogGSL.driver_name() == "orog_gsl"
def test_OrogGSL__runcmd(driverobj):
diff --git a/src/uwtools/tests/drivers/test_schism.py b/src/uwtools/tests/drivers/test_schism.py
index d58fd0876..0c5fea845 100644
--- a/src/uwtools/tests/drivers/test_schism.py
+++ b/src/uwtools/tests/drivers/test_schism.py
@@ -72,4 +72,4 @@ def test_SCHISM_provisioned_rundir(driverobj):
def test_SCHISM_driver_name(driverobj):
- assert driverobj.driver_name == "schism"
+ assert driverobj.driver_name() == SCHISM.driver_name() == "schism"
diff --git a/src/uwtools/tests/drivers/test_sfc_climo_gen.py b/src/uwtools/tests/drivers/test_sfc_climo_gen.py
index f7e81c088..a9355f42d 100644
--- a/src/uwtools/tests/drivers/test_sfc_climo_gen.py
+++ b/src/uwtools/tests/drivers/test_sfc_climo_gen.py
@@ -139,4 +139,4 @@ def test_SfcClimoGen_provisioned_rundir(driverobj):
def test_SfcClimoGen_driver_name(driverobj):
- assert driverobj.driver_name == "sfc_climo_gen"
+ assert driverobj.driver_name() == SfcClimoGen.driver_name() == "sfc_climo_gen"
diff --git a/src/uwtools/tests/drivers/test_shave.py b/src/uwtools/tests/drivers/test_shave.py
index ec1ff4cc8..b6ac9c2a2 100644
--- a/src/uwtools/tests/drivers/test_shave.py
+++ b/src/uwtools/tests/drivers/test_shave.py
@@ -81,7 +81,7 @@ def test_Shave_provisioned_rundir(driverobj):
def test_Shave_driver_name(driverobj):
- assert driverobj.driver_name == "shave"
+ assert driverobj.driver_name() == Shave.driver_name() == "shave"
def test_Shave__runcmd(driverobj):
diff --git a/src/uwtools/tests/drivers/test_support.py b/src/uwtools/tests/drivers/test_support.py
index aa643e728..d9cf9545b 100644
--- a/src/uwtools/tests/drivers/test_support.py
+++ b/src/uwtools/tests/drivers/test_support.py
@@ -59,8 +59,8 @@ def t2(self):
def t3(self):
"@tasks t3"
- @property
- def driver_name(self):
+ @classmethod
+ def driver_name(cls):
pass
@property
diff --git a/src/uwtools/tests/drivers/test_ungrib.py b/src/uwtools/tests/drivers/test_ungrib.py
index 52952fd3b..be2a15388 100644
--- a/src/uwtools/tests/drivers/test_ungrib.py
+++ b/src/uwtools/tests/drivers/test_ungrib.py
@@ -126,7 +126,7 @@ def test_Ungrib_vtable(driverobj):
def test_Ungrib_driver_name(driverobj):
- assert driverobj.driver_name == "ungrib"
+ assert driverobj.driver_name() == Ungrib.driver_name() == "ungrib"
def test_Ungrib__gribfile(driverobj):
diff --git a/src/uwtools/tests/drivers/test_upp.py b/src/uwtools/tests/drivers/test_upp.py
index 0747504da..55b34cb52 100644
--- a/src/uwtools/tests/drivers/test_upp.py
+++ b/src/uwtools/tests/drivers/test_upp.py
@@ -168,7 +168,7 @@ def test_UPP_provisioned_rundir(driverobj):
def test_UPP_driver_name(driverobj):
- assert driverobj.driver_name == "upp"
+ assert driverobj.driver_name() == UPP.driver_name() == "upp"
def test_UPP__namelist_path(driverobj):
diff --git a/src/uwtools/tests/drivers/test_ww3.py b/src/uwtools/tests/drivers/test_ww3.py
index bf97854f9..253080387 100644
--- a/src/uwtools/tests/drivers/test_ww3.py
+++ b/src/uwtools/tests/drivers/test_ww3.py
@@ -80,4 +80,4 @@ def test_WaveWatchIII_restart_directory(driverobj):
def test_WaveWatchIII_driver_name(driverobj):
- assert driverobj.driver_name == "ww3"
+ assert driverobj.driver_name() == WaveWatchIII.driver_name() == "ww3"
diff --git a/src/uwtools/tests/fixtures/testdriver.py b/src/uwtools/tests/fixtures/testdriver.py
index e0320644a..025a7df55 100644
--- a/src/uwtools/tests/fixtures/testdriver.py
+++ b/src/uwtools/tests/fixtures/testdriver.py
@@ -17,6 +17,6 @@ def eighty_eight(self):
yield asset(88, lambda: True)
yield None
- @property
- def driver_name(self):
+ @classmethod
+ def driver_name(cls):
return "testdriver"
diff --git a/src/uwtools/tests/support.py b/src/uwtools/tests/support.py
index 6759d266d..41364ed67 100644
--- a/src/uwtools/tests/support.py
+++ b/src/uwtools/tests/support.py
@@ -13,8 +13,8 @@
def compare_files(path1: Union[Path, str], path2: Union[Path, str]) -> bool:
"""
- Determines whether the two given files are identical up to any number of trailing newlines,
- which are ignored. Print the contents of both files when they do not match.
+ Determine whether the two given files are identical up to any number of trailing newlines, which
+ are ignored. Print the contents of both files when they do not match.
:param path1: Path to first file.
:param path2: Path to second file.
@@ -36,7 +36,7 @@ def compare_files(path1: Union[Path, str], path2: Union[Path, str]) -> bool:
def fixture_pathobj(suffix: str = "") -> Path:
"""
- Returns a pathlib Path object to a test-fixture resource file.
+ Return a pathlib Path object to a test-fixture resource file.
:param suffix: A subpath relative to the location of the unit-test fixture resource files. The
prefix path to the resources files is known to Python and varies based on installation
@@ -49,7 +49,7 @@ def fixture_pathobj(suffix: str = "") -> Path:
def fixture_path(suffix: str = "") -> Path:
"""
- Returns a POSIX path to a test-fixture resource file.
+ Return a POSIX path to a test-fixture resource file.
:param suffix: A subpath relative to the location of the unit-test fixture resource files. The
prefix path to the resources files is known to Python and varies based on installation
diff --git a/src/uwtools/tests/test_cli.py b/src/uwtools/tests/test_cli.py
index 7d36e4349..09bab82e0 100644
--- a/src/uwtools/tests/test_cli.py
+++ b/src/uwtools/tests/test_cli.py
@@ -7,6 +7,7 @@
from argparse import ArgumentParser as Parser
from argparse import _SubParsersAction
from pathlib import Path
+from textwrap import dedent
from unittest.mock import Mock, patch
from pytest import fixture, mark, raises
@@ -52,7 +53,7 @@ def args_config_realize():
@fixture
-def args_dispatch_file():
+def args_dispatch_fs():
return {
"target_dir": "/target/dir",
"config_file": "/config/file",
@@ -102,17 +103,17 @@ def test__add_subparser_config_validate(subparsers):
def test__add_subparser_file(subparsers):
- cli._add_subparser_file(subparsers)
- assert actions(subparsers.choices[STR.file]) == [STR.copy, STR.link]
+ cli._add_subparser_fs(subparsers)
+ assert actions(subparsers.choices[STR.fs]) == [STR.copy, STR.link, STR.makedirs]
def test__add_subparser_file_copy(subparsers):
- cli._add_subparser_file_copy(subparsers)
+ cli._add_subparser_fs_copy(subparsers)
assert subparsers.choices[STR.copy]
def test__add_subparser_file_link(subparsers):
- cli._add_subparser_file_link(subparsers)
+ cli._add_subparser_fs_link(subparsers)
assert subparsers.choices[STR.link]
@@ -177,7 +178,7 @@ def test__dispatch_execute():
"task": "eighty_eight",
"stdin_ok": True,
}
- with patch.object(cli.uwtools.api.driver, "execute") as execute:
+ with patch.object(cli.uwtools.api.execute, "execute") as execute:
cli._dispatch_execute(args=args)
execute.assert_called_once_with(
classname="TestDriver",
@@ -376,35 +377,26 @@ def test__dispatch_config_validate_config_obj():
@mark.parametrize(
- "action, funcname", [(STR.copy, "_dispatch_file_copy"), (STR.link, "_dispatch_file_link")]
+ "action, funcname",
+ [
+ (STR.copy, "_dispatch_fs_copy"),
+ (STR.link, "_dispatch_fs_link"),
+ (STR.makedirs, "_dispatch_fs_makedirs"),
+ ],
)
-def test__dispatch_file(action, funcname):
+def test__dispatch_fs(action, funcname):
args = {STR.action: action}
with patch.object(cli, funcname) as func:
- cli._dispatch_file(args)
+ cli._dispatch_fs(args)
func.assert_called_once_with(args)
-def test__dispatch_file_copy(args_dispatch_file):
- args = args_dispatch_file
- with patch.object(cli.uwtools.api.file, "copy") as copy:
- cli._dispatch_file_copy(args)
- copy.assert_called_once_with(
- target_dir=args["target_dir"],
- config=args["config_file"],
- cycle=args["cycle"],
- leadtime=args["leadtime"],
- keys=args["keys"],
- dry_run=args["dry_run"],
- stdin_ok=args["stdin_ok"],
- )
-
-
-def test__dispatch_file_link(args_dispatch_file):
- args = args_dispatch_file
- with patch.object(cli.uwtools.api.file, "link") as link:
- cli._dispatch_file_link(args)
- link.assert_called_once_with(
+@mark.parametrize("action", ["copy", "link", "makedirs"])
+def test__dispatch_fs_action(action, args_dispatch_fs):
+ args = args_dispatch_fs
+ with patch.object(cli.uwtools.api.fs, action) as a:
+ getattr(cli, f"_dispatch_fs_{action}")(args)
+ a.assert_called_once_with(
target_dir=args["target_dir"],
config=args["config_file"],
cycle=args["cycle"],
@@ -579,7 +571,6 @@ def test__dispatch_template_translate_no_optional():
@mark.parametrize("hours", [0, 24, 168])
def test__dispatch_to_driver(hours):
- name = "adriver"
cycle = dt.datetime.now()
leadtime = dt.timedelta(hours=hours)
args: dict = {
@@ -591,11 +582,12 @@ def test__dispatch_to_driver(hours):
"dry_run": False,
"graph_file": None,
"key_path": ["foo", "bar"],
+ "show_schema": False,
"stdin_ok": True,
}
adriver = Mock()
with patch.object(cli, "import_module", return_value=adriver):
- cli._dispatch_to_driver(name=name, args=args)
+ cli._dispatch_to_driver(name="adriver", args=args)
adriver.execute.assert_called_once_with(
batch=True,
config="/path/to/config",
@@ -609,6 +601,30 @@ def test__dispatch_to_driver(hours):
)
+def test__dispatch_to_driver_no_schema(capsys):
+ adriver = Mock()
+ with patch.object(cli, "import_module", return_value=adriver):
+ with raises(SystemExit):
+ cli._dispatch_to_driver(name="adriver", args={})
+ assert "No TASK specified" in capsys.readouterr().err
+
+
+def test__dispatch_to_driver_show_schema(capsys):
+ adriver = Mock()
+ adriver.schema.return_value = {"fruit": {"b": "banana", "a": "apple"}}
+ with patch.object(cli, "import_module", return_value=adriver):
+ assert cli._dispatch_to_driver(name="adriver", args={"show_schema": True}) is True
+ expected = """
+ {
+ "fruit": {
+ "a": "apple",
+ "b": "banana"
+ }
+ }
+ """
+ assert capsys.readouterr().out == dedent(expected).lstrip()
+
+
@mark.parametrize("quiet", [False, True])
@mark.parametrize("verbose", [False, True])
def test_main_fail_checks(capsys, quiet, verbose):
diff --git a/src/uwtools/tests/test_file.py b/src/uwtools/tests/test_fs.py
similarity index 69%
rename from src/uwtools/tests/test_file.py
rename to src/uwtools/tests/test_fs.py
index 9e3a94b3a..b83a4fbdf 100644
--- a/src/uwtools/tests/test_file.py
+++ b/src/uwtools/tests/test_fs.py
@@ -1,12 +1,16 @@
-# pylint: disable=missing-function-docstring,protected-access,redefined-outer-name
+# pylint: disable=missing-class-docstring
+# pylint: disable=missing-function-docstring
+# pylint: disable=redefined-outer-name
import iotaa
import yaml
from pytest import fixture, mark, raises
-from uwtools import file
+from uwtools import fs
from uwtools.exceptions import UWConfigError
+# Fixtures
+
@fixture
def assets(tmp_path):
@@ -25,13 +29,32 @@ def assets(tmp_path):
return dstdir, cfgdict, cfgfile
+# Helpers
+
+
+class ConcreteStager(fs.Stager):
+ def _validate(self):
+ pass
+
+ @property
+ def _dst_paths(self):
+ return list(self._config.keys())
+
+ @property
+ def _schema(self):
+ return "some-schema"
+
+
+# Tests
+
+
@mark.parametrize("source", ("dict", "file"))
def test_Copier(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
assert not (dstdir / "foo").exists()
assert not (dstdir / "subdir" / "bar").exists()
- file.Copier(target_dir=dstdir, config=config, keys=["a", "b"]).go()
+ fs.Copier(target_dir=dstdir, config=config, keys=["a", "b"]).go()
assert (dstdir / "foo").is_file()
assert (dstdir / "subdir" / "bar").is_file()
@@ -40,7 +63,7 @@ def test_Copier_config_file_dry_run(assets):
dstdir, cfgdict, _ = assets
assert not (dstdir / "foo").exists()
assert not (dstdir / "subdir" / "bar").exists()
- file.Copier(target_dir=dstdir, config=cfgdict, keys=["a", "b"], dry_run=True).go()
+ fs.Copier(target_dir=dstdir, config=cfgdict, keys=["a", "b"], dry_run=True).go()
assert not (dstdir / "foo").exists()
assert not (dstdir / "subdir" / "bar").exists()
iotaa.dryrun(False)
@@ -50,51 +73,49 @@ def test_Copier_no_targetdir_abspath_pass(assets):
dstdir, cfgdict, _ = assets
old = cfgdict["a"]["b"]
cfgdict = {str(dstdir / "foo"): old["foo"], str(dstdir / "bar"): old["subdir/bar"]}
- assets = file.Copier(config=cfgdict).go()
+ assets = fs.Copier(config=cfgdict).go()
assert all(asset.ready() for asset in assets) # type: ignore
def test_Copier_no_targetdir_relpath_fail(assets):
_, cfgdict, _ = assets
with raises(UWConfigError) as e:
- file.Copier(config=cfgdict, keys=["a", "b"]).go()
+ fs.Copier(config=cfgdict, keys=["a", "b"]).go()
errmsg = "Relative path '%s' requires the target directory to be specified"
assert errmsg % "foo" in str(e.value)
@mark.parametrize("source", ("dict", "file"))
-def test_Linker(assets, source):
+def test_FilerStager(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
- assert not (dstdir / "foo").exists()
- assert not (dstdir / "subdir" / "bar").exists()
- file.Linker(target_dir=dstdir, config=config, keys=["a", "b"]).go()
- assert (dstdir / "foo").is_symlink()
- assert (dstdir / "subdir" / "bar").is_symlink()
+ assert fs.FileStager(target_dir=dstdir, config=config, keys=["a", "b"])
@mark.parametrize("source", ("dict", "file"))
-def test_Stager(assets, source):
+def test_Linker(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
- stager = file.Stager(target_dir=dstdir, config=config, keys=["a", "b"])
- assert set(stager._file_map.keys()) == {"foo", "subdir/bar"}
- assert stager._validate() is True
+ assert not (dstdir / "foo").exists()
+ assert not (dstdir / "subdir" / "bar").exists()
+ fs.Linker(target_dir=dstdir, config=config, keys=["a", "b"]).go()
+ assert (dstdir / "foo").is_symlink()
+ assert (dstdir / "subdir" / "bar").is_symlink()
@mark.parametrize("source", ("dict", "file"))
-def test_Stager_bad_key(assets, source):
+def test_Stager__config_block_fail_bad_keypath(assets, source):
dstdir, cfgdict, cfgfile = assets
config = cfgdict if source == "dict" else cfgfile
with raises(UWConfigError) as e:
- file.Stager(target_dir=dstdir, config=config, keys=["a", "x"])
+ ConcreteStager(target_dir=dstdir, config=config, keys=["a", "x"])
assert str(e.value) == "Failed following YAML key(s): a -> x"
@mark.parametrize("val", [None, True, False, "str", 88, 3.14, [], tuple()])
-def test_Stager_empty_val(assets, val):
+def test_Stager__config_block_fails_bad_type(assets, val):
dstdir, cfgdict, _ = assets
cfgdict["a"]["b"] = val
with raises(UWConfigError) as e:
- file.Stager(target_dir=dstdir, config=cfgdict, keys=["a", "b"])
- assert str(e.value) == "No file map found at key path: a -> b"
+ ConcreteStager(target_dir=dstdir, config=cfgdict, keys=["a", "b"])
+ assert str(e.value) == "Expected block not found at key path: a -> b"
diff --git a/src/uwtools/tests/test_rocoto.py b/src/uwtools/tests/test_rocoto.py
index 66032dc64..2c8b3fb67 100644
--- a/src/uwtools/tests/test_rocoto.py
+++ b/src/uwtools/tests/test_rocoto.py
@@ -206,6 +206,15 @@ def test__add_task_dependency_fail_bad_operand(self, instance, root):
with raises(UWConfigError):
instance._add_task_dependency(e=root, config=config)
+ def test__add_task_dependency_metataskdep(self, instance, root):
+ config = {"metataskdep": {"attrs": {"metatask": "foo"}}}
+ instance._add_task_dependency(e=root, config=config)
+ dependency = root[0]
+ assert dependency.tag == "dependency"
+ child = dependency[0]
+ assert child.tag == "metataskdep"
+ assert child.get("metatask") == "foo"
+
@mark.parametrize(
"tag_config",
[("and", {"strneq": {"attrs": {"left": "&RUN_GSI;", "right": "YES"}}})],
diff --git a/src/uwtools/tests/test_scheduler.py b/src/uwtools/tests/test_scheduler.py
index de41507e5..8a7413af4 100644
--- a/src/uwtools/tests/test_scheduler.py
+++ b/src/uwtools/tests/test_scheduler.py
@@ -104,10 +104,10 @@ def test_JobScheduler_get_scheduler_pass(props):
def test_JobScheduler_submit_job(schedulerobj, tmp_path):
runscript = tmp_path / "runscript"
submit_file = tmp_path / "runscript.submit"
- with patch.object(scheduler, "execute") as execute:
- execute.return_value = (True, None)
+ with patch.object(scheduler, "run_shell_cmd") as run_shell_cmd:
+ run_shell_cmd.return_value = (True, None)
assert schedulerobj.submit_job(runscript=runscript, submit_file=submit_file) is True
- execute.assert_called_once_with(
+ run_shell_cmd.assert_called_once_with(
cmd=f"sub {runscript} 2>&1 | tee {submit_file}", cwd=str(tmp_path)
)
diff --git a/src/uwtools/tests/test_schemas.py b/src/uwtools/tests/test_schemas.py
index d270d5cc2..ee0dd3043 100644
--- a/src/uwtools/tests/test_schemas.py
+++ b/src/uwtools/tests/test_schemas.py
@@ -739,7 +739,7 @@ def test_schema_execution_serial():
# files-to-stage
-def test_schema_files_to_stage():
+def test_schema_stage_files():
errors = schema_validator("files-to-stage")
# The input must be an dict:
assert "is not of type 'object'\n" in errors([])
@@ -1212,6 +1212,21 @@ def test_schema_make_solo_mosaic_rundir(make_solo_mosaic_prop):
assert "88 is not of type 'string'\n" in errors(88)
+# makedirs
+
+
+def test_schema_makedirs():
+ errors = schema_validator("makedirs")
+ # The input must be an dict:
+ assert "is not of type 'object'\n" in errors([])
+ # Basic correctness:
+ assert not errors({"makedirs": ["/path/to/dir1", "/path/to/dir2"]})
+ # An empty array is not allowed:
+ assert "[] should be non-empty" in errors({"makedirs": []})
+ # Non-string values are not allowed:
+ assert "True is not of type 'string'\n" in errors({"makedirs": [True]})
+
+
# mpas
diff --git a/src/uwtools/tests/utils/test_processing.py b/src/uwtools/tests/utils/test_processing.py
index cbabefdda..055891789 100644
--- a/src/uwtools/tests/utils/test_processing.py
+++ b/src/uwtools/tests/utils/test_processing.py
@@ -12,10 +12,10 @@
def test_run_failure(caplog):
processing.log.setLevel(logging.INFO)
cmd = "expr 1 / 0"
- success, output = processing.execute(cmd=cmd)
+ success, output = processing.run_shell_cmd(cmd=cmd)
assert "division by zero" in output
assert success is False
- assert logged(caplog, "Executing: %s" % cmd)
+ assert logged(caplog, "Running: %s" % cmd)
assert logged(caplog, " Failed with status: 2")
assert logged(caplog, " Output:")
assert logged(caplog, " expr: division by zero")
@@ -24,9 +24,11 @@ def test_run_failure(caplog):
def test_run_success(caplog, tmp_path):
processing.log.setLevel(logging.INFO)
cmd = "echo hello $FOO"
- success, _ = processing.execute(cmd=cmd, cwd=tmp_path, env={"FOO": "bar"}, log_output=True)
+ success, _ = processing.run_shell_cmd(
+ cmd=cmd, cwd=tmp_path, env={"FOO": "bar"}, log_output=True
+ )
assert success
- assert logged(caplog, "Executing: %s" % cmd)
+ assert logged(caplog, "Running: %s" % cmd)
assert logged(caplog, " in %s" % tmp_path)
assert logged(caplog, " with environment variables:")
assert logged(caplog, " FOO=bar")
diff --git a/src/uwtools/tests/utils/test_tasks.py b/src/uwtools/tests/utils/test_tasks.py
index f663957aa..aa606ed09 100644
--- a/src/uwtools/tests/utils/test_tasks.py
+++ b/src/uwtools/tests/utils/test_tasks.py
@@ -16,6 +16,13 @@ def ready(taskval):
# Tests
+def test_tasks_directory(tmp_path):
+ p = tmp_path / "foo" / "bar"
+ assert not p.is_dir()
+ assert ready(tasks.directory(path=p))
+ assert p.is_dir()
+
+
def test_tasks_executable(tmp_path):
p = tmp_path / "program"
# Ensure that only our temp directory is on the path:
diff --git a/src/uwtools/utils/api.py b/src/uwtools/utils/api.py
index 4df3d2ad7..548b3306b 100644
--- a/src/uwtools/utils/api.py
+++ b/src/uwtools/utils/api.py
@@ -38,7 +38,7 @@ def make_execute(
with_leadtime: Optional[bool] = False,
) -> Callable[..., bool]:
"""
- Returns a function that executes tasks for the given driver.
+ Return a function that executes tasks for the given driver.
:param driver_class: The driver class whose tasks to execute.
:param with_cycle: Does the driver's constructor take a 'cycle' parameter?
diff --git a/src/uwtools/utils/file.py b/src/uwtools/utils/file.py
index 5c102827a..9efd2c324 100644
--- a/src/uwtools/utils/file.py
+++ b/src/uwtools/utils/file.py
@@ -11,13 +11,12 @@
from typing import IO, Any, Generator, Optional, Union
from uwtools.exceptions import UWError
-from uwtools.logging import log
from uwtools.strings import FORMAT
class StdinProxy:
"""
- Reads stdin once but permits multiple reads of its data.
+ Read stdin once and return its cached data.
"""
def __init__(self) -> None:
@@ -43,7 +42,7 @@ def _stdinproxy():
def get_file_format(path: Path) -> str:
"""
- Returns a standardized file format name given a path/filename.
+ Return a standardized file format name given a path/filename.
:param path: A path or filename.
:return: One of a set of supported file-format names.
@@ -52,15 +51,14 @@ def get_file_format(path: Path) -> str:
suffix = Path(path).suffix.replace(".", "")
try:
return FORMAT.formats()[suffix]
- except KeyError as e:
+ except KeyError:
msg = f"Cannot deduce format of '{path}' from unknown extension '{suffix}'"
- log.critical(msg)
- raise UWError(msg) from e
+ raise UWError(msg) from None
def path_if_it_exists(path: str) -> str:
"""
- Returns the given path as an absolute path if it exists, and raises an exception otherwise.
+ Return the given path as an absolute path if it exists, and raises an exception otherwise.
:param path: The filesystem path to test.
:return: The same filesystem path as an absolute path.
@@ -93,7 +91,7 @@ def readable(
def resource_path(suffix: str = "") -> Path:
"""
- Returns a pathlib Path object to a uwtools resource file.
+ Return a pathlib Path object to a uwtools resource file.
:param suffix: A subpath relative to the location of the uwtools resource files. The prefix path
to the resources files is known to Python and varies based on installation location.
diff --git a/src/uwtools/utils/memory.py b/src/uwtools/utils/memory.py
index 445f58a35..d2baca4dd 100644
--- a/src/uwtools/utils/memory.py
+++ b/src/uwtools/utils/memory.py
@@ -25,7 +25,7 @@ def __str__(self):
@property
def measurement(self):
"""
- Returns the measurement (MB, KB, etc.)
+ The measurement (MB, KB, etc.)
"""
if self._measurement is None:
self._measurement = self._value[-2:]
@@ -34,7 +34,7 @@ def measurement(self):
@property
def quantity(self):
"""
- Returns the quantity.
+ The quantity.
"""
if self._quantity is None:
self._quantity = float(self._value[0:-2])
@@ -42,7 +42,7 @@ def quantity(self):
def convert(self, measurement: str):
"""
- Converts the current representation to another measurement.
+ Convert the current representation to another measurement.
"""
quantity = (MAP[self.measurement] / MAP[measurement.upper()]) * self.quantity
diff --git a/src/uwtools/utils/processing.py b/src/uwtools/utils/processing.py
index 1408fe087..05c16efdf 100644
--- a/src/uwtools/utils/processing.py
+++ b/src/uwtools/utils/processing.py
@@ -9,23 +9,23 @@
from uwtools.logging import INDENT, log
-def execute(
+def run_shell_cmd(
cmd: str,
cwd: Optional[Union[Path, str]] = None,
env: Optional[dict[str, str]] = None,
log_output: Optional[bool] = False,
) -> tuple[bool, str]:
"""
- Execute a command in a subshell.
+ Run a command in a shell.
- :param cmd: The command to execute.
- :param cwd: Change to this directory before executing cmd.
- :param env: Environment variables to set before executing cmd.
+ :param cmd: The command to run.
+ :param cwd: Change to this directory before running cmd.
+ :param env: Environment variables to set before running cmd.
:param log_output: Log output from successful cmd? (Error output is always logged.)
:return: A result object providing combined stder/stdout output and success values.
"""
- log.info("Executing: %s", cmd)
+ log.info("Running: %s", cmd)
if cwd:
log.info("%sin %s", INDENT, cwd)
if env:
diff --git a/src/uwtools/utils/tasks.py b/src/uwtools/utils/tasks.py
index 8a8555204..eef1c9deb 100644
--- a/src/uwtools/utils/tasks.py
+++ b/src/uwtools/utils/tasks.py
@@ -10,6 +10,19 @@
from iotaa import asset, external, task
+@task
+def directory(path: Path):
+ """
+ A filesystem directory.
+
+ :param path: Path to the directory.
+ """
+ yield "Directory %s" % path
+ yield asset(path, path.is_dir)
+ yield None
+ path.mkdir(parents=True, exist_ok=True)
+
+
@external
def executable(program: Union[Path, str]):
"""
From c6d6040331d665cffab11bfc29bf9985fc085b15 Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Tue, 3 Sep 2024 10:31:28 -0600
Subject: [PATCH 11/24] UW-666 Jupyter Notebook: File System Tool (#598)
* update_values() -> update_from() (#578)
* DRY out Driver._validate() (#579)
* Move execute() to its own API namespace, disambiguate (#580)
* Config classes to API (#582)
* Simplify unknown-extension error message (#584)
* UW-657 fs makedirs (#572)
* Docstring cleanup (#585)
* GH 586 bad stdin_ok prompt (#587)
* Fix issue with creating a metatask dep. (#589)
The rocoto schema is set up to allow metataskdep entries in the YAML, but the logic was not included in the tool to handle them. This addition fixes that.
* Add --show-schema support to drivers (#588)
* add file system notebook, unit tests, binder links
* Add missing newlines
* Add a table of contents
* Makefile and .gitignore changes from review
* Apply suggestions from code review
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
* Apply review suggestions to all sections of the notebook
* Specific rmtree() targeting and output cell test update
* Apply suggestions from code review
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
---------
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
Co-authored-by: Christina Holt <56881914+christinaholtNOAA@users.noreply.github.com>
---
.gitignore | 3 +-
docs/sections/user_guide/api/fs.rst | 3 +
docs/sections/user_guide/index.rst | 1 +
notebooks/.gitignore | 2 +
notebooks/fixtures/fs/copy-config.yaml | 3 +
notebooks/fixtures/fs/copy-keys-config.yaml | 6 +
notebooks/fixtures/fs/dir-config.yaml | 3 +
notebooks/fixtures/fs/dir-keys-config.yaml | 6 +
notebooks/fixtures/fs/file1.nml | 6 +
notebooks/fixtures/fs/file2.txt | 1 +
notebooks/fixtures/fs/file3.csv | 4 +
notebooks/fixtures/fs/link-config.yaml | 3 +
notebooks/fixtures/fs/link-keys-config.yaml | 6 +
notebooks/fs.ipynb | 1586 +++++++++++++++++++
notebooks/tests/test_fs.py | 100 ++
15 files changed, 1731 insertions(+), 2 deletions(-)
create mode 100644 notebooks/.gitignore
create mode 100644 notebooks/fixtures/fs/copy-config.yaml
create mode 100644 notebooks/fixtures/fs/copy-keys-config.yaml
create mode 100644 notebooks/fixtures/fs/dir-config.yaml
create mode 100644 notebooks/fixtures/fs/dir-keys-config.yaml
create mode 100644 notebooks/fixtures/fs/file1.nml
create mode 100644 notebooks/fixtures/fs/file2.txt
create mode 100644 notebooks/fixtures/fs/file3.csv
create mode 100644 notebooks/fixtures/fs/link-config.yaml
create mode 100644 notebooks/fixtures/fs/link-keys-config.yaml
create mode 100644 notebooks/fs.ipynb
create mode 100644 notebooks/tests/test_fs.py
diff --git a/.gitignore b/.gitignore
index 76191013a..001bce703 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,4 @@
*.egg-info
*.swp
.coverage
-__pycache__
-.ipynb_checkpoints
\ No newline at end of file
+__pycache__
\ No newline at end of file
diff --git a/docs/sections/user_guide/api/fs.rst b/docs/sections/user_guide/api/fs.rst
index 0ac50fc87..63fe32be9 100644
--- a/docs/sections/user_guide/api/fs.rst
+++ b/docs/sections/user_guide/api/fs.rst
@@ -1,5 +1,8 @@
``uwtools.api.fs``
==================
+.. image:: https://mybinder.org/badge_logo.svg
+ :target: https://mybinder.org/v2/gh/ufs-community/uwtools/notebooks?labpath=notebooks%2Ffs.ipynb
+
.. automodule:: uwtools.api.fs
:members:
diff --git a/docs/sections/user_guide/index.rst b/docs/sections/user_guide/index.rst
index 612a08f4d..e0ecea2f3 100644
--- a/docs/sections/user_guide/index.rst
+++ b/docs/sections/user_guide/index.rst
@@ -24,6 +24,7 @@ User Guide
API Jupyter Notebooks
diff --git a/notebooks/.gitignore b/notebooks/.gitignore
new file mode 100644
index 000000000..8867348fd
--- /dev/null
+++ b/notebooks/.gitignore
@@ -0,0 +1,2 @@
+.ipynb_checkpoints
+tmp
\ No newline at end of file
diff --git a/notebooks/fixtures/fs/copy-config.yaml b/notebooks/fixtures/fs/copy-config.yaml
new file mode 100644
index 000000000..ed3879d58
--- /dev/null
+++ b/notebooks/fixtures/fs/copy-config.yaml
@@ -0,0 +1,3 @@
+file1-copy.nml: fixtures/fs/file1.nml
+data/file2-copy.txt: fixtures/fs/file2.txt
+data/file3-copy.csv: fixtures/fs/file3.csv
diff --git a/notebooks/fixtures/fs/copy-keys-config.yaml b/notebooks/fixtures/fs/copy-keys-config.yaml
new file mode 100644
index 000000000..9c4cdc673
--- /dev/null
+++ b/notebooks/fixtures/fs/copy-keys-config.yaml
@@ -0,0 +1,6 @@
+files:
+ to:
+ copy:
+ file1-copy.nml: fixtures/fs/file1.nml
+ data/file2-copy.txt: fixtures/fs/file2.txt
+ data/file3-copy.csv: fixtures/fs/file3.csv
diff --git a/notebooks/fixtures/fs/dir-config.yaml b/notebooks/fixtures/fs/dir-config.yaml
new file mode 100644
index 000000000..1faec9f52
--- /dev/null
+++ b/notebooks/fixtures/fs/dir-config.yaml
@@ -0,0 +1,3 @@
+makedirs:
+ - foo
+ - bar/baz
diff --git a/notebooks/fixtures/fs/dir-keys-config.yaml b/notebooks/fixtures/fs/dir-keys-config.yaml
new file mode 100644
index 000000000..c18ae1478
--- /dev/null
+++ b/notebooks/fixtures/fs/dir-keys-config.yaml
@@ -0,0 +1,6 @@
+path:
+ to:
+ dirs:
+ makedirs:
+ - foo/bar
+ - baz
diff --git a/notebooks/fixtures/fs/file1.nml b/notebooks/fixtures/fs/file1.nml
new file mode 100644
index 000000000..f85ada67c
--- /dev/null
+++ b/notebooks/fixtures/fs/file1.nml
@@ -0,0 +1,6 @@
+&animal
+ name = 'zebra'
+ num_legs = 4
+ diet_type = 'herbivore'
+ location = 'Africa'
+/
diff --git a/notebooks/fixtures/fs/file2.txt b/notebooks/fixtures/fs/file2.txt
new file mode 100644
index 000000000..36fd2ad7e
--- /dev/null
+++ b/notebooks/fixtures/fs/file2.txt
@@ -0,0 +1 @@
+Fun Fact: A group of zebras is called a "zeal" or a "dazzle".
diff --git a/notebooks/fixtures/fs/file3.csv b/notebooks/fixtures/fs/file3.csv
new file mode 100644
index 000000000..6556f0598
--- /dev/null
+++ b/notebooks/fixtures/fs/file3.csv
@@ -0,0 +1,4 @@
+id,location,age
+B524,Botswana,12
+N290,Namibia,4
+K296,Kenya,23
diff --git a/notebooks/fixtures/fs/link-config.yaml b/notebooks/fixtures/fs/link-config.yaml
new file mode 100644
index 000000000..401e77fda
--- /dev/null
+++ b/notebooks/fixtures/fs/link-config.yaml
@@ -0,0 +1,3 @@
+file1-link.nml: fixtures/fs/file1.nml
+file2-link.txt: fixtures/fs/file2.txt
+data/file3-link.csv: fixtures/fs/file3.csv
diff --git a/notebooks/fixtures/fs/link-keys-config.yaml b/notebooks/fixtures/fs/link-keys-config.yaml
new file mode 100644
index 000000000..6abffc60c
--- /dev/null
+++ b/notebooks/fixtures/fs/link-keys-config.yaml
@@ -0,0 +1,6 @@
+files:
+ to:
+ link:
+ file1-link.nml: fixtures/fs/file1.nml
+ file2-link.txt: fixtures/fs/file2.txt
+ data/file3-link.csv: fixtures/fs/file3.csv
diff --git a/notebooks/fs.ipynb b/notebooks/fs.ipynb
new file mode 100644
index 000000000..6f42297a0
--- /dev/null
+++ b/notebooks/fs.ipynb
@@ -0,0 +1,1586 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "2c013e12-a6c6-4786-aa50-900f7da77e6b",
+ "metadata": {},
+ "source": [
+ "# File System Tool\n",
+ "\n",
+ "The `uwtools` API's `fs` module provides functions to copy and link files as well as create directories. \n",
+ "\n",
+ "For more information, please see the uwtools.api.fs Read the Docs page.\n",
+ "\n",
+ "## Table of Contents\n",
+ "\n",
+ "* [Copying Files](#Copying-Files)\n",
+ " * [Failing to copy](#Failing-to-copy)\n",
+ " * [Using the `keys` parameter](#Using-the-keys-parameter)\n",
+ " * [Using the `Copier` class](#Using-the-Copier-class)\n",
+ "* [Linking Files](#Linking-files)\n",
+ " * [Failing to link](#Failing-to-link)\n",
+ " * [Using the `keys` parameter](#Using-the-keys-parameter-)\n",
+ " * [Using the `Linker` class](#Using-the-Linker-class)\n",
+ "* [Creating directories](#Creating-directories)\n",
+ " * [Using the `keys` parameter](#Using-the-keys-parameter--)\n",
+ " * [Using the `MakeDirs` class](#Using-the-MakeDirs-class)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "77864d80-e6f4-48c2-a5d5-88fc512106a9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "from shutil import rmtree\n",
+ "from uwtools.api import fs\n",
+ "from uwtools.api.logging import use_uwtools_logger\n",
+ "\n",
+ "use_uwtools_logger()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "354cf476-720e-4352-8954-0752fd05250f",
+ "metadata": {},
+ "source": [
+ "## Copying Files\n",
+ "\n",
+ "The `copy()` function copies files, automatically creating parent directories as needed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "834654da-dfa9-4997-bcc5-846420381b18",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function copy in module uwtools.api.fs:\n",
+ "\n",
+ "copy(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False, stdin_ok: bool = False) -> bool\n",
+ " Copy files.\n",
+ "\n",
+ " :param config: YAML-file path, or ``dict`` (read ``stdin`` if missing or ``None``).\n",
+ " :param target_dir: Path to target directory.\n",
+ " :param cycle: A datetime object to make available for use in the config.\n",
+ " :param leadtime: A timedelta object to make available for use in the config.\n",
+ " :param keys: YAML keys leading to file dst/src block.\n",
+ " :param dry_run: Do not copy files.\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: ``True`` if all copies were created.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.copy)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0585971b-47c6-48aa-9f1f-d5890cbb2061",
+ "metadata": {},
+ "source": [
+ "Files to be copied are specified by a mapping from keys destination-pathname keys to source-pathname values, either in a YAML file or a a Python ``dict``."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "a959522f-d769-48c6-918d-d42776b3600a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "file1-copy.nml: fixtures/fs/file1.nml\n",
+ "data/file2-copy.txt: fixtures/fs/file2.txt\n",
+ "data/file3-copy.csv: fixtures/fs/file3.csv\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/copy-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5885fd76-78de-4814-ad7b-dfd6df18a07d",
+ "metadata": {},
+ "source": [
+ "With these instructions, `copy()` creates a copy of each given file with the given name and in the given subdirectory. Copies are created in the directory indicated by `target_dir`. Paths can be provided either as a string or Path object. Any directories in the targeted paths for copying will be created if they don't already exist. `True` is returned upon a successful copy."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "a6aff6e3-815c-496e-81d7-d8756be9c232",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:21:27] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:21:27] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:21:27] INFO File copies: Initial state: Pending\n",
+ "[2024-08-30T15:21:27] INFO File copies: Checking requirements\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file1.nml -> tmp/copy-target/file1-copy.nml: Initial state: Pending\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file1.nml -> tmp/copy-target/file1-copy.nml: Checking requirements\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file1.nml -> tmp/copy-target/file1-copy.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file1.nml -> tmp/copy-target/file1-copy.nml: Executing\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file1.nml -> tmp/copy-target/file1-copy.nml: Final state: Ready\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file2.txt -> tmp/copy-target/data/file2-copy.txt: Initial state: Pending\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file2.txt -> tmp/copy-target/data/file2-copy.txt: Checking requirements\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file2.txt -> tmp/copy-target/data/file2-copy.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file2.txt -> tmp/copy-target/data/file2-copy.txt: Executing\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file2.txt -> tmp/copy-target/data/file2-copy.txt: Final state: Ready\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file3.csv -> tmp/copy-target/data/file3-copy.csv: Initial state: Pending\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file3.csv -> tmp/copy-target/data/file3-copy.csv: Checking requirements\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file3.csv -> tmp/copy-target/data/file3-copy.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file3.csv -> tmp/copy-target/data/file3-copy.csv: Executing\n",
+ "[2024-08-30T15:21:27] INFO Copy fixtures/fs/file3.csv -> tmp/copy-target/data/file3-copy.csv: Final state: Ready\n",
+ "[2024-08-30T15:21:27] INFO File copies: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/copy-target\", ignore_errors=True)\n",
+ "fs.copy(\n",
+ " config=\"fixtures/fs/copy-config.yaml\",\n",
+ " target_dir=Path(\"tmp/copy-target\")\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5c05369f-3cf6-4576-8689-df98ed9d151d",
+ "metadata": {},
+ "source": [
+ "Examining the target directory, we can see that the copies of the files have been made with their specified names and in their specified directories."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "e67fdb49-beef-4006-9e36-1a22829f21fc",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/copy-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ ├── file2-copy.txt\n",
+ "│ └── file3-copy.csv\n",
+ "└── file1-copy.nml\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/copy-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "90e17445-3d87-4894-8211-8c737f7579d6",
+ "metadata": {},
+ "source": [
+ "### Failing to copy\n",
+ "\n",
+ "A configuration can be provided as a dictionary instead as this example demonstrates. However, `missing-file.nml` does not exist. The function provides a warning and returns `False`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "b1fa6662-c4f3-4f7a-9b5d-8ee258cd6e0e",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:21:35] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:21:35] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:21:35] INFO File copies: Initial state: Pending\n",
+ "[2024-08-30T15:21:35] INFO File copies: Checking requirements\n",
+ "[2024-08-30T15:21:35] INFO Copy fixtures/fs/missing-file.nml -> tmp/copy-target/missing-copy.nml: Initial state: Pending\n",
+ "[2024-08-30T15:21:35] INFO Copy fixtures/fs/missing-file.nml -> tmp/copy-target/missing-copy.nml: Checking requirements\n",
+ "[2024-08-30T15:21:35] WARNING File fixtures/fs/missing-file.nml: State: Pending (EXTERNAL)\n",
+ "[2024-08-30T15:21:35] INFO Copy fixtures/fs/missing-file.nml -> tmp/copy-target/missing-copy.nml: Requirement(s) pending\n",
+ "[2024-08-30T15:21:35] WARNING Copy fixtures/fs/missing-file.nml -> tmp/copy-target/missing-copy.nml: Final state: Pending\n",
+ "[2024-08-30T15:21:35] WARNING File copies: Final state: Pending\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "fs.copy(\n",
+ " config={\"missing-copy.nml\":\"fixtures/fs/missing-file.nml\"},\n",
+ " target_dir=\"tmp/copy-target\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6f93ced5-0953-462b-9739-e74333c94e64",
+ "metadata": {},
+ "source": [
+ "The missing copy does not appear in the target directory."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "ab122bde-f483-4981-8308-fc6d4a90e50d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/copy-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ ├── file2-copy.txt\n",
+ "│ └── file3-copy.csv\n",
+ "└── file1-copy.nml\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/copy-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b2527839-c217-428d-a686-c684a682c0e8",
+ "metadata": {},
+ "source": [
+ "### Using the `keys` parameter\n",
+ "\n",
+ "Consider the following configuration, in which the destination/source mapping is not located at the top level of the configuration:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "1f567844-ff8d-4e7f-87be-dffae9e15643",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "files:\n",
+ " to:\n",
+ " copy:\n",
+ " file1-copy.nml: fixtures/fs/file1.nml\n",
+ " data/file2-copy.txt: fixtures/fs/file2.txt\n",
+ " data/file3-copy.csv: fixtures/fs/file3.csv\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/copy-keys-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5311866f-a1f5-4243-81a8-2c52172e091a",
+ "metadata": {},
+ "source": [
+ "Without additional information, `copy()` would raise a `UWConfigError` given this configuration. However, the list of keys leading to the destination/source mapping can be provided with the `keys` parameter:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "dda3e407-a1a2-4b11-823a-3b6fdc39f67a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:21:47] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:21:47] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:21:47] INFO File copies: Initial state: Pending\n",
+ "[2024-08-30T15:21:47] INFO File copies: Checking requirements\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file1.nml -> tmp/copy-keys-target/file1-copy.nml: Initial state: Pending\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file1.nml -> tmp/copy-keys-target/file1-copy.nml: Checking requirements\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file1.nml -> tmp/copy-keys-target/file1-copy.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file1.nml -> tmp/copy-keys-target/file1-copy.nml: Executing\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file1.nml -> tmp/copy-keys-target/file1-copy.nml: Final state: Ready\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file2.txt -> tmp/copy-keys-target/data/file2-copy.txt: Initial state: Pending\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file2.txt -> tmp/copy-keys-target/data/file2-copy.txt: Checking requirements\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file2.txt -> tmp/copy-keys-target/data/file2-copy.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file2.txt -> tmp/copy-keys-target/data/file2-copy.txt: Executing\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file2.txt -> tmp/copy-keys-target/data/file2-copy.txt: Final state: Ready\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file3.csv -> tmp/copy-keys-target/data/file3-copy.csv: Initial state: Pending\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file3.csv -> tmp/copy-keys-target/data/file3-copy.csv: Checking requirements\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file3.csv -> tmp/copy-keys-target/data/file3-copy.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file3.csv -> tmp/copy-keys-target/data/file3-copy.csv: Executing\n",
+ "[2024-08-30T15:21:47] INFO Copy fixtures/fs/file3.csv -> tmp/copy-keys-target/data/file3-copy.csv: Final state: Ready\n",
+ "[2024-08-30T15:21:47] INFO File copies: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/copy-keys-target\", ignore_errors=True)\n",
+ "fs.copy(\n",
+ " config=\"fixtures/fs/copy-keys-config.yaml\",\n",
+ " target_dir=\"tmp/copy-keys-target\",\n",
+ " keys=[\"files\",\"to\",\"copy\"]\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d62ae369-69f9-4003-b5d3-d7b5908f23af",
+ "metadata": {},
+ "source": [
+ "With this information provided, the copy is successful."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "59c67e22-fe98-4e74-8b0b-b40e24a804e8",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/copy-keys-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ ├── file2-copy.txt\n",
+ "│ └── file3-copy.csv\n",
+ "└── file1-copy.nml\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/copy-keys-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1a1adba8-2daf-4fb6-b224-980b134f011c",
+ "metadata": {},
+ "source": [
+ "### Using the `Copier` class\n",
+ "\n",
+ "An alternative to using `copy()` is to instantiate a `Copier` object , then call its `go()` method."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "7434dee7-fb52-4d9b-b2a1-d414165f3186",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on class Copier in module uwtools.fs:\n",
+ "\n",
+ "class Copier(FileStager)\n",
+ " | Copier(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " |\n",
+ " | Stage files by copying.\n",
+ " |\n",
+ " | Method resolution order:\n",
+ " | Copier\n",
+ " | FileStager\n",
+ " | Stager\n",
+ " | abc.ABC\n",
+ " | builtins.object\n",
+ " |\n",
+ " | Methods defined here:\n",
+ " |\n",
+ " | go = __iotaa_tasks__(*args, **kwargs) -> '_AssetT'\n",
+ " | Copy files.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data and other attributes defined here:\n",
+ " |\n",
+ " | __abstractmethods__ = frozenset()\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from Stager:\n",
+ " |\n",
+ " | __init__(self, config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " | Stage files and directories.\n",
+ " |\n",
+ " | :param config: YAML-file path, or dict (read stdin if missing or None).\n",
+ " | :param target_dir: Path to target directory.\n",
+ " | :param cycle: A datetime object to make available for use in the config.\n",
+ " | :param leadtime: A timedelta object to make available for use in the config.\n",
+ " | :param keys: YAML keys leading to file dst/src block.\n",
+ " | :param dry_run: Do not copy files.\n",
+ " | :raises: UWConfigError if config fails validation.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data descriptors inherited from Stager:\n",
+ " |\n",
+ " | __dict__\n",
+ " | dictionary for instance variables\n",
+ " |\n",
+ " | __weakref__\n",
+ " | list of weak references to the object\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.Copier)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "061ac341-96cb-4af6-94ce-4f1e4d342b63",
+ "metadata": {},
+ "source": [
+ "A `Copier` object is instantiated using the same parameters as `copy()`, but copying is not performed until `Copier.go()` is called."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "578cc091-c0eb-4293-8dbd-ee74a69a0940",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:21:56] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:21:56] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:21:56] INFO File copies: Initial state: Pending\n",
+ "[2024-08-30T15:21:56] INFO File copies: Checking requirements\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file1.nml -> tmp/copier-target/file1-copy.nml: Initial state: Pending\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file1.nml -> tmp/copier-target/file1-copy.nml: Checking requirements\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file1.nml -> tmp/copier-target/file1-copy.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file1.nml -> tmp/copier-target/file1-copy.nml: Executing\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file1.nml -> tmp/copier-target/file1-copy.nml: Final state: Ready\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file2.txt -> tmp/copier-target/data/file2-copy.txt: Initial state: Pending\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file2.txt -> tmp/copier-target/data/file2-copy.txt: Checking requirements\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file2.txt -> tmp/copier-target/data/file2-copy.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file2.txt -> tmp/copier-target/data/file2-copy.txt: Executing\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file2.txt -> tmp/copier-target/data/file2-copy.txt: Final state: Ready\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file3.csv -> tmp/copier-target/data/file3-copy.csv: Initial state: Pending\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file3.csv -> tmp/copier-target/data/file3-copy.csv: Checking requirements\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file3.csv -> tmp/copier-target/data/file3-copy.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file3.csv -> tmp/copier-target/data/file3-copy.csv: Executing\n",
+ "[2024-08-30T15:21:56] INFO Copy fixtures/fs/file3.csv -> tmp/copier-target/data/file3-copy.csv: Final state: Ready\n",
+ "[2024-08-30T15:21:56] INFO File copies: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[Asset(ref=PosixPath('tmp/copier-target/file1-copy.nml'), ready=),\n",
+ " Asset(ref=PosixPath('tmp/copier-target/data/file2-copy.txt'), ready=),\n",
+ " Asset(ref=PosixPath('tmp/copier-target/data/file3-copy.csv'), ready=)]"
+ ]
+ },
+ "execution_count": 12,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/copier-target\", ignore_errors=True)\n",
+ "copier = fs.Copier(\n",
+ " config=\"fixtures/fs/copy-config.yaml\",\n",
+ " target_dir=\"tmp/copier-target\"\n",
+ ")\n",
+ "copier.go()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "842f638a-bf97-4d40-bb40-0f37cc03ad9b",
+ "metadata": {},
+ "source": [
+ "Once `Copier.go()` is called, copies are created in the same way as they would have with `copy()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "c6aaac2b-bb72-433d-8ad4-349a1056cfa3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/copier-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ ├── file2-copy.txt\n",
+ "│ └── file3-copy.csv\n",
+ "└── file1-copy.nml\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/copier-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "76f144f9-0a2f-48ad-ae83-14bd7a97353e",
+ "metadata": {},
+ "source": [
+ "## Linking files\n",
+ "\n",
+ "The `link()` function creates symbolic links to files, automatically creating parent directories as needed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "404d051e-18e1-4927-a24f-cbe98ab01ce9",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function link in module uwtools.api.fs:\n",
+ "\n",
+ "link(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False, stdin_ok: bool = False) -> bool\n",
+ " Link files.\n",
+ "\n",
+ " :param config: YAML-file path, or ``dict`` (read ``stdin`` if missing or ``None``).\n",
+ " :param target_dir: Path to target directory.\n",
+ " :param cycle: A datetime object to make available for use in the config.\n",
+ " :param leadtime: A timedelta object to make available for use in the config.\n",
+ " :param keys: YAML keys leading to file dst/src block.\n",
+ " :param dry_run: Do not link files.\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: ``True`` if all links were created.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.link)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "710edac4-ba97-4599-a0f3-bc75ba2210e2",
+ "metadata": {},
+ "source": [
+ "Links to be created are specified by a mapping from keys destination-pathname keys to source-pathname values, either in a YAML file or a Python ``dict``."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "097b896c-aef4-48ac-aea5-eb2d463d172b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "file1-link.nml: fixtures/fs/file1.nml\n",
+ "file2-link.txt: fixtures/fs/file2.txt\n",
+ "data/file3-link.csv: fixtures/fs/file3.csv\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/link-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1acd36eb-a5e7-4451-9d22-bfe8798cb4b0",
+ "metadata": {},
+ "source": [
+ "With these instructions, `link()` creates a symbolic link of each given file with the given name and in the given subdirectory. Links are created in the directory indicated by `target_dir`. Paths can be provided either as a string or Path object. Any directories in the targeted paths will be created if they don't already exist. `True` is returned upon a successful run."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "b6317f8a-c5fb-4114-93fa-236df3fd8805",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:08] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:22:08] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:08] INFO File links: Initial state: Pending\n",
+ "[2024-08-30T15:22:08] INFO File links: Checking requirements\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file1-link.nml -> fixtures/fs/file1.nml: Initial state: Pending\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file1-link.nml -> fixtures/fs/file1.nml: Checking requirements\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file1-link.nml -> fixtures/fs/file1.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file1-link.nml -> fixtures/fs/file1.nml: Executing\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file1-link.nml -> fixtures/fs/file1.nml: Final state: Ready\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file2-link.txt -> fixtures/fs/file2.txt: Initial state: Pending\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file2-link.txt -> fixtures/fs/file2.txt: Checking requirements\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file2-link.txt -> fixtures/fs/file2.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file2-link.txt -> fixtures/fs/file2.txt: Executing\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/file2-link.txt -> fixtures/fs/file2.txt: Final state: Ready\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/data/file3-link.csv -> fixtures/fs/file3.csv: Initial state: Pending\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/data/file3-link.csv -> fixtures/fs/file3.csv: Checking requirements\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/data/file3-link.csv -> fixtures/fs/file3.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/data/file3-link.csv -> fixtures/fs/file3.csv: Executing\n",
+ "[2024-08-30T15:22:08] INFO Link tmp/link-target/data/file3-link.csv -> fixtures/fs/file3.csv: Final state: Ready\n",
+ "[2024-08-30T15:22:08] INFO File links: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 16,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/link-target\", ignore_errors=True)\n",
+ "fs.link(\n",
+ " config=Path(\"fixtures/fs/link-config.yaml\"),\n",
+ " target_dir=\"tmp/link-target\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28fe0009-11cb-4ec4-b203-221e2a59cedb",
+ "metadata": {},
+ "source": [
+ "Examining the target directory, we can see that the links have been created with their specified names and in their specified directories."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "id": "b31ca50e-01c4-4665-81e0-de70a75ceb2a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/link-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ └── \u001b[01;36mfile3-link.csv\u001b[0m -> ../../../fixtures/fs/file3.csv\n",
+ "├── \u001b[01;36mfile1-link.nml\u001b[0m -> ../../fixtures/fs/file1.nml\n",
+ "└── \u001b[01;36mfile2-link.txt\u001b[0m -> ../../fixtures/fs/file2.txt\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/link-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e0661083-6e83-490f-82c5-19098b0f1b3c",
+ "metadata": {},
+ "source": [
+ "### Failing to link\n",
+ "\n",
+ "A configuration can be provided as a dictionary instead as this example demonstrates. However, `missing-file.nml` does not exist. The function provides a warning and returns `False`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "id": "59d93133-891d-4903-a965-23607cc72474",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:11] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:22:11] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:11] INFO File links: Initial state: Pending\n",
+ "[2024-08-30T15:22:11] INFO File links: Checking requirements\n",
+ "[2024-08-30T15:22:11] INFO Link tmp/link-target/missing-link.nml -> fixtures/fs/missing-file.nml: Initial state: Pending\n",
+ "[2024-08-30T15:22:11] INFO Link tmp/link-target/missing-link.nml -> fixtures/fs/missing-file.nml: Checking requirements\n",
+ "[2024-08-30T15:22:11] WARNING Filesystem item fixtures/fs/missing-file.nml: State: Pending (EXTERNAL)\n",
+ "[2024-08-30T15:22:11] INFO Link tmp/link-target/missing-link.nml -> fixtures/fs/missing-file.nml: Requirement(s) pending\n",
+ "[2024-08-30T15:22:11] WARNING Link tmp/link-target/missing-link.nml -> fixtures/fs/missing-file.nml: Final state: Pending\n",
+ "[2024-08-30T15:22:11] WARNING File links: Final state: Pending\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 18,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "fs.link(\n",
+ " config={\"missing-link.nml\":\"fixtures/fs/missing-file.nml\"},\n",
+ " target_dir=\"tmp/link-target\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7a3ca573-c160-4afa-8a96-4165b01eecfe",
+ "metadata": {},
+ "source": [
+ "The missing link does not appear in the target directory."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "7a6e94b9-1161-4f41-9333-55736aec07b3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/link-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ └── \u001b[01;36mfile3-link.csv\u001b[0m -> ../../../fixtures/fs/file3.csv\n",
+ "├── \u001b[01;36mfile1-link.nml\u001b[0m -> ../../fixtures/fs/file1.nml\n",
+ "└── \u001b[01;36mfile2-link.txt\u001b[0m -> ../../fixtures/fs/file2.txt\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/link-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b887c95e-f71f-4a26-b709-d410a3c30c2e",
+ "metadata": {},
+ "source": [
+ "### Using the `keys` parameter \n",
+ "\n",
+ "Consider the following configuration, in which the destination/source mapping is not located at the top level of the configuration:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "id": "1de6cbd4-3b10-4b18-a8a5-c0cd21064bd3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "files:\n",
+ " to:\n",
+ " link:\n",
+ " file1-link.nml: fixtures/fs/file1.nml\n",
+ " file2-link.txt: fixtures/fs/file2.txt\n",
+ " data/file3-link.csv: fixtures/fs/file3.csv\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/link-keys-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9977ee46-17da-419e-821b-a32fac5139f8",
+ "metadata": {},
+ "source": [
+ "Without additional information, `link()` would raise a `UWConfigError` given this configuration. However, the list of keys leading to the destination/source mapping can be provided with the `keys` parameter:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "id": "ee4bf2a3-4101-4d95-afd5-120e95e64550",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:22] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:22:22] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:22] INFO File links: Initial state: Pending\n",
+ "[2024-08-30T15:22:22] INFO File links: Checking requirements\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file1-link.nml -> fixtures/fs/file1.nml: Initial state: Pending\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file1-link.nml -> fixtures/fs/file1.nml: Checking requirements\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file1-link.nml -> fixtures/fs/file1.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file1-link.nml -> fixtures/fs/file1.nml: Executing\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file1-link.nml -> fixtures/fs/file1.nml: Final state: Ready\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file2-link.txt -> fixtures/fs/file2.txt: Initial state: Pending\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file2-link.txt -> fixtures/fs/file2.txt: Checking requirements\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file2-link.txt -> fixtures/fs/file2.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file2-link.txt -> fixtures/fs/file2.txt: Executing\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/file2-link.txt -> fixtures/fs/file2.txt: Final state: Ready\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/data/file3-link.csv -> fixtures/fs/file3.csv: Initial state: Pending\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/data/file3-link.csv -> fixtures/fs/file3.csv: Checking requirements\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/data/file3-link.csv -> fixtures/fs/file3.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/data/file3-link.csv -> fixtures/fs/file3.csv: Executing\n",
+ "[2024-08-30T15:22:22] INFO Link tmp/link-keys-target/data/file3-link.csv -> fixtures/fs/file3.csv: Final state: Ready\n",
+ "[2024-08-30T15:22:22] INFO File links: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 21,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/link-keys-target\", ignore_errors=True)\n",
+ "fs.link(\n",
+ " config=\"fixtures/fs/link-keys-config.yaml\",\n",
+ " target_dir=\"tmp/link-keys-target\",\n",
+ " keys=[\"files\",\"to\",\"link\"]\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d331d715-b6f1-4a9a-a207-2fb296aec4af",
+ "metadata": {},
+ "source": [
+ "With this information provided, the links are successfully created."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "id": "1393ae73-798b-49c0-9b68-e8ed28ad1df0",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/link-keys-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ └── \u001b[01;36mfile3-link.csv\u001b[0m -> ../../../fixtures/fs/file3.csv\n",
+ "├── \u001b[01;36mfile1-link.nml\u001b[0m -> ../../fixtures/fs/file1.nml\n",
+ "└── \u001b[01;36mfile2-link.txt\u001b[0m -> ../../fixtures/fs/file2.txt\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/link-keys-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "29a9457a-e4f3-460a-b873-cf1bf236c9de",
+ "metadata": {},
+ "source": [
+ "### Using the `Linker` class\n",
+ "\n",
+ "An alternative to using `link()` is to instantiate a `Linker` object , then call its `go()` method."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "id": "b1e5d3a2-7003-4449-9483-440236f66df7",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on class Linker in module uwtools.fs:\n",
+ "\n",
+ "class Linker(FileStager)\n",
+ " | Linker(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " |\n",
+ " | Stage files by linking.\n",
+ " |\n",
+ " | Method resolution order:\n",
+ " | Linker\n",
+ " | FileStager\n",
+ " | Stager\n",
+ " | abc.ABC\n",
+ " | builtins.object\n",
+ " |\n",
+ " | Methods defined here:\n",
+ " |\n",
+ " | go = __iotaa_tasks__(*args, **kwargs) -> '_AssetT'\n",
+ " | Link files.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data and other attributes defined here:\n",
+ " |\n",
+ " | __abstractmethods__ = frozenset()\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from Stager:\n",
+ " |\n",
+ " | __init__(self, config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " | Stage files and directories.\n",
+ " |\n",
+ " | :param config: YAML-file path, or dict (read stdin if missing or None).\n",
+ " | :param target_dir: Path to target directory.\n",
+ " | :param cycle: A datetime object to make available for use in the config.\n",
+ " | :param leadtime: A timedelta object to make available for use in the config.\n",
+ " | :param keys: YAML keys leading to file dst/src block.\n",
+ " | :param dry_run: Do not copy files.\n",
+ " | :raises: UWConfigError if config fails validation.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data descriptors inherited from Stager:\n",
+ " |\n",
+ " | __dict__\n",
+ " | dictionary for instance variables\n",
+ " |\n",
+ " | __weakref__\n",
+ " | list of weak references to the object\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.Linker)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3312a98b-9f5d-41bd-ad02-f69d291cc947",
+ "metadata": {},
+ "source": [
+ "A `Linker` object is instantiated using the same parameters as `link()`, but links are not created until `Linker.go()` is called."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "id": "ecfa9e89-9fbd-4352-babc-dfa5b91afe6a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:28] INFO Validating config against internal schema: files-to-stage\n",
+ "[2024-08-30T15:22:28] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:28] INFO File links: Initial state: Pending\n",
+ "[2024-08-30T15:22:28] INFO File links: Checking requirements\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file1-link.nml -> fixtures/fs/file1.nml: Initial state: Pending\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file1-link.nml -> fixtures/fs/file1.nml: Checking requirements\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file1-link.nml -> fixtures/fs/file1.nml: Requirement(s) ready\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file1-link.nml -> fixtures/fs/file1.nml: Executing\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file1-link.nml -> fixtures/fs/file1.nml: Final state: Ready\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file2-link.txt -> fixtures/fs/file2.txt: Initial state: Pending\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file2-link.txt -> fixtures/fs/file2.txt: Checking requirements\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file2-link.txt -> fixtures/fs/file2.txt: Requirement(s) ready\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file2-link.txt -> fixtures/fs/file2.txt: Executing\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/file2-link.txt -> fixtures/fs/file2.txt: Final state: Ready\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/data/file3-link.csv -> fixtures/fs/file3.csv: Initial state: Pending\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/data/file3-link.csv -> fixtures/fs/file3.csv: Checking requirements\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/data/file3-link.csv -> fixtures/fs/file3.csv: Requirement(s) ready\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/data/file3-link.csv -> fixtures/fs/file3.csv: Executing\n",
+ "[2024-08-30T15:22:28] INFO Link tmp/linker-target/data/file3-link.csv -> fixtures/fs/file3.csv: Final state: Ready\n",
+ "[2024-08-30T15:22:28] INFO File links: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[Asset(ref=PosixPath('tmp/linker-target/file1-link.nml'), ready=),\n",
+ " Asset(ref=PosixPath('tmp/linker-target/file2-link.txt'), ready=),\n",
+ " Asset(ref=PosixPath('tmp/linker-target/data/file3-link.csv'), ready=)]"
+ ]
+ },
+ "execution_count": 24,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/linker-target\", ignore_errors=True)\n",
+ "linker = fs.Linker(\n",
+ " config=\"fixtures/fs/link-config.yaml\",\n",
+ " target_dir=\"tmp/linker-target\"\n",
+ ")\n",
+ "linker.go()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8d2cbb32-cabb-498e-b4db-414e3ac2cf1d",
+ "metadata": {},
+ "source": [
+ "Once `Linker.go()` is called, links are created in the same way as they would have with `link()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "id": "6695f7bb-7ab7-42d1-9d2c-0bef7341147d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/linker-target\u001b[0m\n",
+ "├── \u001b[01;34mdata\u001b[0m\n",
+ "│ └── \u001b[01;36mfile3-link.csv\u001b[0m -> ../../../fixtures/fs/file3.csv\n",
+ "├── \u001b[01;36mfile1-link.nml\u001b[0m -> ../../fixtures/fs/file1.nml\n",
+ "└── \u001b[01;36mfile2-link.txt\u001b[0m -> ../../fixtures/fs/file2.txt\n",
+ "\n",
+ "2 directories, 3 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/linker-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bd367e2a-e44c-4a5c-9600-4d86719f7d36",
+ "metadata": {},
+ "source": [
+ "## Creating directories\n",
+ "\n",
+ "The `makedirs()` function creates directories."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 26,
+ "id": "43b381d1-8dc2-4ea6-924c-e21149f05e7f",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function makedirs in module uwtools.api.fs:\n",
+ "\n",
+ "makedirs(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False, stdin_ok: bool = False) -> bool\n",
+ " Make directories.\n",
+ "\n",
+ " :param config: YAML-file path, or ``dict`` (read ``stdin`` if missing or ``None``).\n",
+ " :param target_dir: Path to target directory.\n",
+ " :param cycle: A datetime object to make available for use in the config.\n",
+ " :param leadtime: A timedelta object to make available for use in the config.\n",
+ " :param keys: YAML keys leading to file dst/src block.\n",
+ " :param dry_run: Do not link files.\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: ``True`` if all directories were made.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.makedirs)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "83b88d7e-f4cf-4358-98f9-106b47bd5d9f",
+ "metadata": {},
+ "source": [
+ "Directories to be created are specified by either a configuration YAML file or a Python ``dict``. A `makedirs` key must be included with a list of directories to create as its value."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 27,
+ "id": "2f946927-509f-4cd6-a7ec-2d36f4d17318",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "makedirs:\n",
+ " - foo\n",
+ " - bar/baz\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/dir-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "eec8938c-1e2a-482b-aa0e-9e6e89dcf200",
+ "metadata": {},
+ "source": [
+ "With these instructions, `makedirs()` creates each directory in the list within the directory indicated by `target_dir`. Paths can be provided either as a string or Path object. `True` is returned upon a successful run."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "id": "fdd4e832-3bc5-4c7a-9b31-e387a4e7d48b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:34] INFO Validating config against internal schema: makedirs\n",
+ "[2024-08-30T15:22:34] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:34] INFO Directories: Initial state: Pending\n",
+ "[2024-08-30T15:22:34] INFO Directories: Checking requirements\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/foo: Initial state: Pending\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/foo: Checking requirements\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/foo: Requirement(s) ready\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/foo: Executing\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/foo: Final state: Ready\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/bar/baz: Initial state: Pending\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/bar/baz: Checking requirements\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/bar/baz: Requirement(s) ready\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/bar/baz: Executing\n",
+ "[2024-08-30T15:22:34] INFO Directory tmp/dir-target/bar/baz: Final state: Ready\n",
+ "[2024-08-30T15:22:34] INFO Directories: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 28,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/dir-target\", ignore_errors=True)\n",
+ "fs.makedirs(\n",
+ " config=\"fixtures/fs/dir-config.yaml\",\n",
+ " target_dir=Path(\"tmp/dir-target\")\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "df4245f6-7083-45ee-b56e-9697a50db5da",
+ "metadata": {},
+ "source": [
+ "Examining the target directory, we can see that the directories have been created with their specified names."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 29,
+ "id": "c00ec8cc-964a-498e-bd8f-a3686a468dc3",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/dir-target\u001b[0m\n",
+ "├── \u001b[01;34mbar\u001b[0m\n",
+ "│ └── \u001b[01;34mbaz\u001b[0m\n",
+ "└── \u001b[01;34mfoo\u001b[0m\n",
+ "\n",
+ "4 directories, 0 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/dir-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "329e939d-0f6d-412c-a36a-4682fe99609a",
+ "metadata": {},
+ "source": [
+ "### Using the `keys` parameter \n",
+ "\n",
+ "Consider the following configuration, in which the destination/source mapping is not located at the top level of the configuration:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 30,
+ "id": "3a93956d-0acf-4c37-87bf-83c0d5287644",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "path:\n",
+ " to:\n",
+ " dirs:\n",
+ " makedirs:\n",
+ " - foo/bar\n",
+ " - baz\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/fs/dir-keys-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "909ff3ea-8577-4b91-94fc-6ce6effe4bec",
+ "metadata": {},
+ "source": [
+ "Without additional information, `makedirs()` would raise a `UWConfigError` given this configuration. However, the list of keys leading to the destination/source mapping can be provided with the `keys` parameter:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 31,
+ "id": "91549822-e85e-4d41-8860-1da05d713f75",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:42] INFO Validating config against internal schema: makedirs\n",
+ "[2024-08-30T15:22:42] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:42] INFO Directories: Initial state: Pending\n",
+ "[2024-08-30T15:22:42] INFO Directories: Checking requirements\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/foo/bar: Initial state: Pending\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/foo/bar: Checking requirements\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/foo/bar: Requirement(s) ready\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/foo/bar: Executing\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/foo/bar: Final state: Ready\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/baz: Initial state: Pending\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/baz: Checking requirements\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/baz: Requirement(s) ready\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/baz: Executing\n",
+ "[2024-08-30T15:22:42] INFO Directory tmp/dir-keys-target/baz: Final state: Ready\n",
+ "[2024-08-30T15:22:42] INFO Directories: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 31,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/dir-keys-target\", ignore_errors=True)\n",
+ "fs.makedirs(\n",
+ " config=\"fixtures/fs/dir-keys-config.yaml\",\n",
+ " target_dir=\"tmp/dir-keys-target\",\n",
+ " keys=[\"path\",\"to\",\"dirs\"]\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5b7ed362-2edb-438a-bb9f-e2dd7d505379",
+ "metadata": {},
+ "source": [
+ "With this information provided, the directories are successfully created."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 32,
+ "id": "cb4ded9c-0de1-4010-af75-fbb7becd3fbc",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/dir-keys-target\u001b[0m\n",
+ "├── \u001b[01;34mbaz\u001b[0m\n",
+ "└── \u001b[01;34mfoo\u001b[0m\n",
+ " └── \u001b[01;34mbar\u001b[0m\n",
+ "\n",
+ "4 directories, 0 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/dir-keys-target"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "742ce55e-fded-4961-931d-49bd75c09901",
+ "metadata": {},
+ "source": [
+ "### Using the `MakeDirs` class\n",
+ "\n",
+ "An alternative to using `makedirs()` is to instantiate a `MakeDirs` object , then call its `go()` method."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 33,
+ "id": "7fe53736-d8e8-4ca9-ab2b-87729934fc19",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on class MakeDirs in module uwtools.fs:\n",
+ "\n",
+ "class MakeDirs(Stager)\n",
+ " | MakeDirs(config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " |\n",
+ " | Make directories.\n",
+ " |\n",
+ " | Method resolution order:\n",
+ " | MakeDirs\n",
+ " | Stager\n",
+ " | abc.ABC\n",
+ " | builtins.object\n",
+ " |\n",
+ " | Methods defined here:\n",
+ " |\n",
+ " | go = __iotaa_tasks__(*args, **kwargs) -> '_AssetT'\n",
+ " | Make directories.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data and other attributes defined here:\n",
+ " |\n",
+ " | __abstractmethods__ = frozenset()\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from Stager:\n",
+ " |\n",
+ " | __init__(self, config: Union[dict, str, pathlib.Path, NoneType] = None, target_dir: Union[str, pathlib.Path, NoneType] = None, cycle: Optional[datetime.datetime] = None, leadtime: Optional[datetime.timedelta] = None, keys: Optional[list[str]] = None, dry_run: bool = False) -> None\n",
+ " | Stage files and directories.\n",
+ " |\n",
+ " | :param config: YAML-file path, or dict (read stdin if missing or None).\n",
+ " | :param target_dir: Path to target directory.\n",
+ " | :param cycle: A datetime object to make available for use in the config.\n",
+ " | :param leadtime: A timedelta object to make available for use in the config.\n",
+ " | :param keys: YAML keys leading to file dst/src block.\n",
+ " | :param dry_run: Do not copy files.\n",
+ " | :raises: UWConfigError if config fails validation.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data descriptors inherited from Stager:\n",
+ " |\n",
+ " | __dict__\n",
+ " | dictionary for instance variables\n",
+ " |\n",
+ " | __weakref__\n",
+ " | list of weak references to the object\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(fs.MakeDirs)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cf3b50de-4a8b-4f51-96bb-a477b2c53430",
+ "metadata": {},
+ "source": [
+ "A `MakeDirs` object is instantiated using the same parameters as `makedirs()`, but directories are not created until `MakeDirs.go()` is called."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 34,
+ "id": "950d6b43-6db7-40df-b645-beaa1369cfa4",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-08-30T15:22:48] INFO Validating config against internal schema: makedirs\n",
+ "[2024-08-30T15:22:48] INFO 0 UW schema-validation errors found\n",
+ "[2024-08-30T15:22:48] INFO Directories: Initial state: Pending\n",
+ "[2024-08-30T15:22:48] INFO Directories: Checking requirements\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/foo: Initial state: Pending\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/foo: Checking requirements\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/foo: Requirement(s) ready\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/foo: Executing\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/foo: Final state: Ready\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/bar/baz: Initial state: Pending\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/bar/baz: Checking requirements\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/bar/baz: Requirement(s) ready\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/bar/baz: Executing\n",
+ "[2024-08-30T15:22:48] INFO Directory tmp/makedirs-target/bar/baz: Final state: Ready\n",
+ "[2024-08-30T15:22:48] INFO Directories: Final state: Ready\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[Asset(ref=PosixPath('tmp/makedirs-target/foo'), ready=),\n",
+ " Asset(ref=PosixPath('tmp/makedirs-target/bar/baz'), ready=)]"
+ ]
+ },
+ "execution_count": 34,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rmtree(\"tmp/makedirs-target\", ignore_errors=True)\n",
+ "dirs_stager = fs.MakeDirs(\n",
+ " config=\"fixtures/fs/dir-config.yaml\",\n",
+ " target_dir=\"tmp/makedirs-target\"\n",
+ ")\n",
+ "dirs_stager.go()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9ae5f357-1d56-4670-a8d7-8546e73c4efa",
+ "metadata": {},
+ "source": [
+ "Once `MakeDirs.go()` is called, directories are created in the same way as they would have with `makedirs()`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 35,
+ "id": "a61fb9ac-df2f-4e39-9f66-bfb789c39117",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[01;34mtmp/makedirs-target\u001b[0m\n",
+ "├── \u001b[01;34mbar\u001b[0m\n",
+ "│ └── \u001b[01;34mbaz\u001b[0m\n",
+ "└── \u001b[01;34mfoo\u001b[0m\n",
+ "\n",
+ "4 directories, 0 files\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "tree tmp/makedirs-target"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python [conda env:DEV-uwtools] *",
+ "language": "python",
+ "name": "conda-env-DEV-uwtools-py"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/tests/test_fs.py b/notebooks/tests/test_fs.py
new file mode 100644
index 000000000..b5b0b2607
--- /dev/null
+++ b/notebooks/tests/test_fs.py
@@ -0,0 +1,100 @@
+import os
+
+import yaml
+from testbook import testbook
+
+
+def test_copy():
+ # Get the config files as text and dictionaries.
+ with open("fixtures/fs/copy-config.yaml", "r", encoding="utf-8") as f:
+ config_str = f.read().rstrip()
+ config_dict = yaml.safe_load(config_str)
+ with open("fixtures/fs/copy-keys-config.yaml", "r", encoding="utf-8") as f:
+ config_keys_str = f.read().rstrip()
+ config_keys_dict = yaml.safe_load(config_keys_str)
+ with testbook("fs.ipynb", execute=True) as tb:
+ # Each key in each config should have created a copy of the file given by each value.
+ for item in config_dict.items():
+ with open("tmp/copy-target/" + item[0], "r", encoding="utf-8") as f:
+ copy_dst_txt = f.read().rstrip()
+ with open("tmp/copier-target/" + item[0], "r", encoding="utf-8") as f:
+ copier_dst_txt = f.read().rstrip()
+ with open(item[1], "r", encoding="utf-8") as f:
+ src_txt = f.read().rstrip()
+ assert copy_dst_txt == src_txt
+ assert copier_dst_txt == src_txt
+ for item in config_keys_dict["files"]["to"]["copy"].items():
+ with open("tmp/copy-keys-target/" + item[0], "r", encoding="utf-8") as f:
+ copy_keys_dst_txt = f.read().rstrip()
+ with open(item[1], "r", encoding="utf-8") as f:
+ src_txt = f.read().rstrip()
+ assert copy_keys_dst_txt == src_txt
+ # Ensure that cell output text matches expectations.
+ assert tb.cell_output_text(5) == config_str
+ assert "True" in tb.cell_output_text(7)
+ assert "False" in tb.cell_output_text(11)
+ assert tb.cell_output_text(13) == tb.cell_output_text(9)
+ assert tb.cell_output_text(15) == config_keys_str
+ assert "True" in tb.cell_output_text(17)
+
+
+def test_link():
+ # Get the config files as text and dictionaries.
+ with open("fixtures/fs/link-config.yaml", "r", encoding="utf-8") as f:
+ config_str = f.read().rstrip()
+ config_dict = yaml.safe_load(config_str)
+ with open("fixtures/fs/link-keys-config.yaml", "r", encoding="utf-8") as f:
+ config_keys_str = f.read().rstrip()
+ config_keys_dict = yaml.safe_load(config_keys_str)
+ with testbook("fs.ipynb", execute=True) as tb:
+ # Each key in each config should have created a symlink of the file given by each value.
+ for item in config_dict.items():
+ link_path = "tmp/link-target/" + item[0]
+ linker_path = "tmp/linker-target/" + item[0]
+ with open(link_path, "r", encoding="utf-8") as f:
+ link_dst_txt = f.read().rstrip()
+ with open(linker_path, "r", encoding="utf-8") as f:
+ linker_dst_txt = f.read().rstrip()
+ with open(item[1], "r", encoding="utf-8") as f:
+ src_txt = f.read().rstrip()
+ assert os.path.islink(link_path)
+ assert link_dst_txt == src_txt
+ assert os.path.islink(linker_path)
+ assert linker_dst_txt == src_txt
+ for item in config_keys_dict["files"]["to"]["link"].items():
+ link_keys_path = "tmp/link-keys-target/" + item[0]
+ with open(link_keys_path, "r", encoding="utf-8") as f:
+ link_keys_dst_txt = f.read().rstrip()
+ with open(item[1], "r", encoding="utf-8") as f:
+ src_txt = f.read().rstrip()
+ assert os.path.islink(link_keys_path)
+ assert link_keys_dst_txt == src_txt
+ # Ensure that cell output text matches expectations.
+ assert tb.cell_output_text(29) == config_str
+ assert "True" in tb.cell_output_text(31)
+ assert "False" in tb.cell_output_text(35)
+ assert tb.cell_output_text(37) == tb.cell_output_text(33)
+ assert tb.cell_output_text(39) == config_keys_str
+ assert "True" in tb.cell_output_text(41)
+
+
+def test_makedirs():
+ # Get the config files as text and dictionaries.
+ with open("fixtures/fs/dir-config.yaml", "r", encoding="utf-8") as f:
+ config_str = f.read().rstrip()
+ config_dict = yaml.safe_load(config_str)
+ with open("fixtures/fs/dir-keys-config.yaml", "r", encoding="utf-8") as f:
+ config_keys_str = f.read().rstrip()
+ config_keys_dict = yaml.safe_load(config_keys_str)
+ with testbook("fs.ipynb", execute=True) as tb:
+ # Each value in each config should have been created as one or more subdirectories.
+ for subdir in config_dict["makedirs"]:
+ assert os.path.exists("tmp/dir-target/" + subdir)
+ assert os.path.exists("tmp/makedirs-target/" + subdir)
+ for subdir in config_keys_dict["path"]["to"]["dirs"]["makedirs"]:
+ assert os.path.exists("tmp/dir-keys-target/" + subdir)
+ # Ensure that cell output text matches expectations.
+ assert tb.cell_output_text(53) == config_str
+ assert "True" in tb.cell_output_text(55)
+ assert tb.cell_output_text(59) == config_keys_str
+ assert "True" in tb.cell_output_text(61)
From 3561964a823aba0597b9f6b92d85122b03731604 Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Wed, 25 Sep 2024 11:04:24 -0600
Subject: [PATCH 12/24] UW-667 Jupyter notebook: Config Tool Part I (#607)
* WIP
* WIP
* WIP
* Add config notebook, unit tests, Binder link & button
* add newline
* add markdown comment
* add suggestions from review
* apply suggestion from review
* updating markdown comments and RtD links
* Apply suggestions from code review
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
* change error demo style and update section links
* Apply suggestions from review
* update testing
* update RtD links and add uwtools version
---------
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
---
docs/sections/user_guide/api/config.rst | 3 +
docs/sections/user_guide/index.rst | 3 +-
notebooks/config.ipynb | 996 +++++++++++++++++++++
notebooks/fixtures/config/base-config.nml | 5 +
notebooks/fixtures/config/get-config.yaml | 2 +
notebooks/fixtures/config/keys-config.yaml | 4 +
notebooks/template.ipynb | 2 +-
notebooks/tests/test_config.py | 73 ++
8 files changed, 1086 insertions(+), 2 deletions(-)
create mode 100644 notebooks/config.ipynb
create mode 100644 notebooks/fixtures/config/base-config.nml
create mode 100644 notebooks/fixtures/config/get-config.yaml
create mode 100644 notebooks/fixtures/config/keys-config.yaml
create mode 100644 notebooks/tests/test_config.py
diff --git a/docs/sections/user_guide/api/config.rst b/docs/sections/user_guide/api/config.rst
index 5f689e1e7..0de57f368 100644
--- a/docs/sections/user_guide/api/config.rst
+++ b/docs/sections/user_guide/api/config.rst
@@ -1,6 +1,9 @@
``uwtools.api.config``
======================
+.. image:: https://mybinder.org/badge_logo.svg
+ :target: https://mybinder.org/v2/gh/ufs-community/uwtools/notebooks?labpath=notebooks%2Fconfig.ipynb
+
.. automodule:: uwtools.api.config
:inherited-members: UserDict
:members:
diff --git a/docs/sections/user_guide/index.rst b/docs/sections/user_guide/index.rst
index e0ecea2f3..31b4eed42 100644
--- a/docs/sections/user_guide/index.rst
+++ b/docs/sections/user_guide/index.rst
@@ -23,8 +23,9 @@ User Guide
diff --git a/notebooks/config.ipynb b/notebooks/config.ipynb
new file mode 100644
index 000000000..bba503764
--- /dev/null
+++ b/notebooks/config.ipynb
@@ -0,0 +1,996 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "d023d283-7e1d-4e75-95b8-5033bea42a59",
+ "metadata": {},
+ "source": [
+ "# Config Tool\n",
+ "\n",
+ "The `uwtools` API's `config` module provides functions to create and manipulate configuration files, objects, and dictionaries.\n",
+ "\n",
+ "Tested on `uwtools` version 2.4.2. For more information, please see the uwtools.api.config Read the Docs page.\n",
+ "\n",
+ "## Table of Contents\n",
+ "\n",
+ "* [Getting Config Objects](#Getting-Config-Objects)\n",
+ "* [Config Depth Limitations](#Config-Depth-Limitations)\n",
+ "* [Realizing Configs](#Realizing-Configs)\n",
+ " * [Updating Configs](#Updating-Configs)\n",
+ " * [Using the `key_path` Parameter](#Using-the-key_path-Parameter)\n",
+ " * [Using the `values_needed` Parameter](#Using-the-values_needed-Parameter)\n",
+ " * [Using the `total` Parameter](#Using-the-total-Parameter)\n",
+ "* [Realizing Configs to a Dictionary](#Realizing-Configs-to-a-Dictionary)\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "6d02d033-0992-4990-861d-3f80d09d7083",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "from uwtools.api import config\n",
+ "from uwtools.api.logging import use_uwtools_logger\n",
+ "\n",
+ "use_uwtools_logger()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "212594bb-379f-4441-805e-af0dbabe1815",
+ "metadata": {},
+ "source": [
+ "## Getting Config Objects\n",
+ "\n",
+ "The `config` tool can create configuration objects given a Python ``dict`` or a file in one of five different formats: FieldTable, INI, Fortran namelist, Shell, or YAML. `config.get_yaml_config` is demonstrated here, but the config module also has similar functions for each of the other supported formats: `get_fieldtable_config()`, `get_ini_config()`, `get_nml_config()`, and `get_sh_config()`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "ab0e21c3-a4b6-404c-bffd-e0d393d9b0a2",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function get_yaml_config in module uwtools.api.config:\n",
+ "\n",
+ "get_yaml_config(config: Union[dict, str, pathlib.Path, NoneType] = None, stdin_ok: bool = False) -> uwtools.config.formats.yaml.YAMLConfig\n",
+ " Get a ``YAMLConfig`` object.\n",
+ "\n",
+ " :param config: YAML file or ``dict`` (``None`` => read ``stdin``).\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: An initialized ``YAMLConfig`` object.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(config.get_yaml_config)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "606da5b3-4bff-4148-a9a5-908aa7dd5e8c",
+ "metadata": {},
+ "source": [
+ "The `stdin_ok` argument can be used to permit reads from `stdin`, but this is a rare use case beyond the scope of this notebook that will not be discussed here.\n",
+ "\n",
+ "`get_yaml_config()` can take input from a Python `dict` or a YAML file like the one below.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "c6e049df-38f6-4879-8e0d-68356226d94b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "greeting: Hello\n",
+ "recipient: World\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/get-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c2f72448-d35e-4a0b-a371-cb47c7b3338b",
+ "metadata": {},
+ "source": [
+ "Paths to config files can be provided either as a string or Path object. Since `get_yaml_config()` is used here, a `YAMLConfig` object is returned.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "cc3020a6-4eb4-4830-9263-a9fc8fac7450",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "greeting: Hello\n",
+ "recipient: World\n"
+ ]
+ }
+ ],
+ "source": [
+ "config1 = config.get_yaml_config(\n",
+ " config=Path(\"fixtures/config/get-config.yaml\")\n",
+ ")\n",
+ "print(type(config1))\n",
+ "print(config1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b7bcd736-ff78-4e8b-957f-b348b812c5f6",
+ "metadata": {},
+ "source": [
+ "Providing a Python `dict` will create a UW `Config` object with format matching the function used.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "f01ac223-4a02-40ba-822f-8e66ad39f313",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "message:\n",
+ " greeting: Hi\n",
+ " recipient: Earth\n"
+ ]
+ }
+ ],
+ "source": [
+ "input_config = {\"message\": {\"greeting\":\"Hi\", \"recipient\":\"Earth\"}}\n",
+ "config2 = config.get_yaml_config(\n",
+ " config=input_config\n",
+ ")\n",
+ "print(config2)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dc745e95-d1ce-435c-a488-13b761979e36",
+ "metadata": {},
+ "source": [
+ "## Config Depth Limitations\n",
+ "\n",
+ "Some config formats have limitations on the depth of their nested configs. Shell configs, for example, may only contain top-level, bash-syntax `key=value` pairs.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "28d23ac5-52a0-45bc-bfee-98d9ea518ca2",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "greeting=Salutations\n",
+ "recipient=Mars"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.get_sh_config(\n",
+ " config={\"greeting\":\"Salutations\", \"recipient\":\"Mars\"}\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3dd7292f-cbd1-4e45-b641-1e213a4ead07",
+ "metadata": {},
+ "source": [
+ "Shell configs cannot be nested, and any attempt to do so will raise a `UWConfigError`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "b3a0a5bc-9d1b-4d48-a05f-be6f94fb6e1d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Cannot instantiate depth-1 SHConfig with depth-2 config\n"
+ ]
+ }
+ ],
+ "source": [
+ "try: \n",
+ " config.get_sh_config(\n",
+ " config={\"message\": {\"greeting\":\"Salutations\", \"recipient\":\"Mars\"}}\n",
+ " )\n",
+ "except Exception as e:\n",
+ " print(e)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7677bdaa-8707-4dff-b812-91b4521f4820",
+ "metadata": {},
+ "source": [
+ "When creating INI configs, exactly one level of nesting is required so that each key-value pair is contained within a section. The top level keys become sections, which are contained within square brackets `[]`. Read more about INI configuration files here.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "6837e75b-bd20-4c3b-bd33-650e4b4f9f23",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[message]\n",
+ "greeting = Salutations\n",
+ "recipient = Mars"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.get_ini_config(\n",
+ " config={\"message\": {\"greeting\":\"Salutations\", \"recipient\":\"Mars\"}}\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "04ab0d52-75cc-4227-8058-a9a1faba7b54",
+ "metadata": {},
+ "source": [
+ "Either more or fewer levels of nesting will raise a `UWConfigError`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "9655b36b-2d39-4fc1-b3b8-9cb3443cf4b8",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Cannot instantiate depth-2 INIConfig with depth-1 config\n"
+ ]
+ }
+ ],
+ "source": [
+ "try:\n",
+ " config.get_ini_config(\n",
+ " config={\"greeting\":\"Salutations\", \"recipient\":\"Mars\"}\n",
+ " )\n",
+ "except Exception as e:\n",
+ " print(e)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e4403333-c247-465f-9a75-f96d1be914f6",
+ "metadata": {},
+ "source": [
+ "## Realizing Configs\n",
+ "\n",
+ "The `config.realize()` function writes config files to disk or `stdout` with the ability to render Jinja2 expressions and add/update values.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "2c3f1b75-b26f-4893-beb7-37a58c09f511",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function realize in module uwtools.api.config:\n",
+ "\n",
+ "realize(input_config: Union[uwtools.config.formats.base.Config, pathlib.Path, dict, str, NoneType] = None, input_format: Optional[str] = None, update_config: Union[uwtools.config.formats.base.Config, pathlib.Path, dict, str, NoneType] = None, update_format: Optional[str] = None, output_file: Union[str, pathlib.Path, NoneType] = None, output_format: Optional[str] = None, key_path: Optional[list[Union[str, int]]] = None, values_needed: bool = False, total: bool = False, dry_run: bool = False, stdin_ok: bool = False) -> dict\n",
+ " Realize a config based on a base input config and an optional update config.\n",
+ "\n",
+ " The input config may be specified as a filesystem path, a ``dict``, or a ``Config`` object. When it\n",
+ " is not, it will be read from ``stdin``.\n",
+ "\n",
+ " If an update config is specified, it is merged onto the input config, augmenting or overriding base\n",
+ " values. It may be specified as a filesystem path, a ``dict``, or a ``Config`` object. When it is\n",
+ " not, it will be read from ``stdin``.\n",
+ "\n",
+ " At most one of the input config or the update config may be left unspecified, in which case the\n",
+ " other will be read from ``stdin``. If neither filename or format is specified for the update config, no\n",
+ " update will be performed.\n",
+ "\n",
+ " The output destination may be specified as a filesystem path. When it is not, it will be written to\n",
+ " ``stdout``.\n",
+ "\n",
+ " If ``values_needed`` is ``True``, a report of values needed to realize the config is logged. In\n",
+ " ``dry_run`` mode, output is written to ``stderr``.\n",
+ "\n",
+ " If ``total`` is ``True``, an exception will be raised if any Jinja2 variables/expressions cannot be\n",
+ " rendered. Otherwise, such variables/expressions will be passed through unchanged in the output.\n",
+ "\n",
+ " Recognized file extensions are: ini, nml, sh, yaml\n",
+ "\n",
+ " :param input_config: Input config file (``None`` => read ``stdin``).\n",
+ " :param input_format: Format of the input config (optional if file's extension is recognized).\n",
+ " :param update_config: Update config file (``None`` => read ``stdin``).\n",
+ " :param update_format: Format of the update config (optional if file's extension is recognized).\n",
+ " :param output_file: Output config file (``None`` => write to ``stdout``).\n",
+ " :param output_format: Format of the output config (optional if file's extension is recognized).\n",
+ " :param key_path: Path through keys to the desired output block.\n",
+ " :param values_needed: Report complete, missing, and template values.\n",
+ " :param total: Require rendering of all Jinja2 variables/expressions.\n",
+ " :param dry_run: Log output instead of writing to output.\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: The ``dict`` representation of the realized config.\n",
+ " :raises: UWConfigRealizeError if ``total`` is ``True`` and any Jinja2 variable/expression was not rendered.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(config.realize)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "501da514-a654-4511-928f-b2ad7db102b2",
+ "metadata": {},
+ "source": [
+ "The `input_config` parameter takes a config from a string path, Path object, Python `dict`, or UW `Config` object like the `YAMLConfig` object from the Getting Config Objects section. The `input_format` argument must be provided for `dict` inputs or for files without recognized extensions. Configs are written to `stdout` if `output_file` is unspecified or explicitly set to `None`, or to the file specified by `output_file`. The `output_format` argument must be provided when writing to `stdout` or to a file without a recognized extension. Recognized extensions are: `.ini`, `.nml`, `.sh`, and `.yaml`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "77873e14-db3c-417d-be7a-2ba12c9a38f6",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'greeting': 'Hello', 'recipient': 'World'}"
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize(\n",
+ " input_config=config1,\n",
+ " output_file=Path('tmp/config1.yaml')\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fd1823c4-6c3d-4d4a-a614-4d1238588bdd",
+ "metadata": {},
+ "source": [
+ "The `realize()` method returns a dict version of the config regardless of input type, and the file is written in the YAML format as indicated by the file extension.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "4f237a73-da83-4632-990f-644632b15cd9",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "greeting: Hello\n",
+ "recipient: World\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat tmp/config1.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "902c4863-e772-49c6-88f2-31e1ab76b418",
+ "metadata": {},
+ "source": [
+ "Input and output formats are not required to match. This can be used to convert some configs from one format to another. YAML configs can be converted to configs of other recognized formats so long as the depth restrictions of the output format are met. All configs of recognized formats can be converted into YAML configs. Keep in mind that some formats are unable to express some types (for example, Shell configs can't express a value as an `int` while a Fortran namelist can) so type information may be lost when converting between formats.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "b8854dc6-9dd2-4843-99e4-278b116b9767",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'greeting': 'Hello', 'recipient': 'World'}"
+ ]
+ },
+ "execution_count": 13,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize(\n",
+ " input_config='fixtures/config/get-config.yaml',\n",
+ " input_format='yaml',\n",
+ " output_file='tmp/realize-config.sh',\n",
+ " output_format='sh'\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4032ab38-f6c7-45d1-bff7-f70d23832f26",
+ "metadata": {},
+ "source": [
+ "Here a Shell config is created from a YAML config.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "0a59a3e8-27b5-4daa-a924-941aceaad157",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "greeting=Hello\n",
+ "recipient=World\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat tmp/realize-config.sh"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ef0136e4-f549-467f-9341-f77f84738bb0",
+ "metadata": {},
+ "source": [
+ "### Updating Configs\n",
+ "\n",
+ "Configs can be updated by providing a second config with the `update_config` parameter. If the update config contains keys that match the base config, the base config values for those keys will be overwritten. Once updated, if the config contains Jinja2 expressions, like the one below, they will be rendered in the config wherever possible.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "2d72bbc1-e438-48b2-8bd7-554b598c6f24",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = \"{{ id }}\"\n",
+ " message = \"{{ greeting }}, {{ recipient }}!\"\n",
+ " sent = .FALSE.\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/base-config.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "67b07a34-1bea-402f-9cec-f33a2e519d27",
+ "metadata": {},
+ "source": [
+ "Here, the update config provides values that will update two of the Jinja2 expressions and override one key with a new value.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "7648fdd5-5752-4bf3-b366-db8da1eac601",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'memo': {'sender_id': '{{ id }}',\n",
+ " 'message': 'Salutations, Mars!',\n",
+ " 'sent': True,\n",
+ " 'greeting': 'Salutations',\n",
+ " 'recipient': 'Mars'}}"
+ ]
+ },
+ "execution_count": 16,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize(\n",
+ " input_config='fixtures/config/base-config.nml',\n",
+ " update_config={\"memo\": {\"greeting\":\"Salutations\", \"recipient\":\"Mars\", \"sent\": True}},\n",
+ " output_file='tmp/updated-config.nml'\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bdac7f6c-4452-4137-9104-784449584100",
+ "metadata": {},
+ "source": [
+ "All of the key-value pairs were added to the updated config, and the base config was rendered where the appropriate values were provided. However, not all Jinja2 expressions are required to be rendered: An `id` key was not provided in the update config, so the expression referencing it was not rendered.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "id": "f4538965-d3b0-4c0c-a878-b6852f8d8ab0",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = '{{ id }}'\n",
+ " message = 'Salutations, Mars!'\n",
+ " sent = .true.\n",
+ " greeting = 'Salutations'\n",
+ " recipient = 'Mars'\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat tmp/updated-config.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a20024ac-0f33-4000-942d-29c99dc0502e",
+ "metadata": {},
+ "source": [
+ "### Using the `key_path` Parameter\n",
+ "\n",
+ "Consider the following config file, where the desired keys and values are not at the top level.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "id": "7ce0e917-f0d0-4302-9c8c-b136ffc5410a",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "keys:\n",
+ " to:\n",
+ " config:\n",
+ " message: \"{{ greeting }}, {{ recipient }}!\"\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/keys-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "633778ca-391c-4ba4-b3b4-b3f599af5d41",
+ "metadata": {},
+ "source": [
+ "The `key_path` parameter allows only a portion of the config, identified by following a given list of keys, to be written to a file or, in this case, to `stdout`. Note that the key-value pairs from the update config are used to render values, but don't appear in the config written to `stdout`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "00150efa-848c-44eb-ac0c-dab3845546b8",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "message: Good morning, Venus!\n"
+ ]
+ }
+ ],
+ "source": [
+ "_ = config.realize(\n",
+ " input_config=\"fixtures/config/keys-config.yaml\",\n",
+ " update_config={\"greeting\": \"Good morning\", \"recipient\": \"Venus\"},\n",
+ " output_file=None,\n",
+ " output_format='yaml',\n",
+ " key_path=['keys', 'to', 'config']\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "903ea23f-4625-48f0-9efc-1c0a106d5bf6",
+ "metadata": {},
+ "source": [
+ "### Using the `values_needed` Parameter\n",
+ "\n",
+ "Consider the config file below, which contains unrendered Jinja2 expressions.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "id": "928a23d7-8ba9-4217-935d-01563bb36cb6",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = \"{{ id }}\"\n",
+ " message = \"{{ greeting }}, {{ recipient }}!\"\n",
+ " sent = .FALSE.\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/base-config.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7d878080-55ae-4233-bb65-37cfa5ef7cff",
+ "metadata": {},
+ "source": [
+ "Setting `values_needed` to `True` will allow logging of keys that contain unrendered Jinja2 expressions and their values. A logger needs to be initialized for this information to be displayed. The config is not written and the returned `dict` is empty.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "id": "d22c692d-e98e-4f88-bdac-a369f0a1962f",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-09-23T16:13:23] INFO Keys that are complete:\n",
+ "[2024-09-23T16:13:23] INFO memo\n",
+ "[2024-09-23T16:13:23] INFO memo.sent\n",
+ "[2024-09-23T16:13:23] INFO \n",
+ "[2024-09-23T16:13:23] INFO Keys with unrendered Jinja2 variables/expressions:\n",
+ "[2024-09-23T16:13:23] INFO memo.sender_id: {{ id }}\n",
+ "[2024-09-23T16:13:23] INFO memo.message: {{ greeting }}, {{ recipient }}!\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{}"
+ ]
+ },
+ "execution_count": 21,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize(\n",
+ " input_config='fixtures/config/base-config.nml',\n",
+ " output_file=None,\n",
+ " output_format='nml',\n",
+ " values_needed=True\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ddbc989a-a9ed-4649-8e87-f74d1ff43b89",
+ "metadata": {},
+ "source": [
+ "### Using the `total` Parameter\n",
+ "\n",
+ "The `total` parameter is used to specify that all Jinja2 expressions must be rendered before the final config is written. Consider the config below which contains multiple expressions.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "id": "647538d9-cd22-4f94-b15b-c34d68a324da",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = \"{{ id }}\"\n",
+ " message = \"{{ greeting }}, {{ recipient }}!\"\n",
+ " sent = .FALSE.\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/base-config.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7c0d64be-c97c-4d07-b636-54b1c8bb5d0c",
+ "metadata": {},
+ "source": [
+ "As was shown in the Updating Configs section, by default not all Jinja2 expressions are required to be rendered. However, when `total` is set to `True` and not enough values are provided to fully realize the config, a `UWConfigRealizeError` is raised. Notice that values are provided for `greeting` and `recipient`, but not for `id`.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "id": "ba360102-f558-4dba-b0b6-c6f550c7d40f",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Config could not be totally realized\n"
+ ]
+ }
+ ],
+ "source": [
+ "try:\n",
+ " config.realize(\n",
+ " input_config='fixtures/config/base-config.nml',\n",
+ " update_config={\"memo\": {\"greeting\":\"Salutations\", \"recipient\":\"Mars\", \"sent\":True}},\n",
+ " output_file='tmp/config-total.nml',\n",
+ " total=True\n",
+ " )\n",
+ "except Exception as e:\n",
+ " print(e)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4048efdd-42f4-4400-bdba-1c8e0001d8f6",
+ "metadata": {},
+ "source": [
+ "With all values provided to fully render the config, `realize()` writes the complete config without error.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "id": "e891a446-f699-460d-b4a8-568d9d4cf631",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'memo': {'sender_id': '321',\n",
+ " 'message': 'Salutations, Mars!',\n",
+ " 'sent': True,\n",
+ " 'greeting': 'Salutations',\n",
+ " 'recipient': 'Mars',\n",
+ " 'id': 321}}"
+ ]
+ },
+ "execution_count": 24,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize(\n",
+ " input_config='fixtures/config/base-config.nml',\n",
+ " update_config={\"memo\": {\"greeting\":\"Salutations\", \"recipient\":\"Mars\", \"sent\":True, \"id\":321}},\n",
+ " output_file='tmp/config-total.nml',\n",
+ " total=True\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "07876d41-dad2-4c30-adba-f635050708ed",
+ "metadata": {},
+ "source": [
+ "The newly created config file is free from any unrendered Jinja2 expressions.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "id": "aeab0ec5-7e6e-4309-b484-4de5dd9324b5",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = '321'\n",
+ " message = 'Salutations, Mars!'\n",
+ " sent = .true.\n",
+ " greeting = 'Salutations'\n",
+ " recipient = 'Mars'\n",
+ " id = 321\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat tmp/config-total.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e121dab6-5d16-40b0-aa16-161c329a8e9a",
+ "metadata": {},
+ "source": [
+ "## Realizing Configs to a Dictionary\n",
+ "\n",
+ "The `config.realize_to_dict()` function has the ability to manipulate config values, and returns the config as a Python `dict` just as `realize()` does. However, a config won't be written to a file or to `stdout`. Like `realize()`, input or update configs can be Python dictionaries, UW `Config` objects, or files like the one below.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 26,
+ "id": "bdc98ce1-e213-41ac-b1f2-03bd52238e30",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "greeting: Hello\n",
+ "recipient: World\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/get-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "eeacc37f-b808-4d0e-9e29-ff8aa1fc2ff2",
+ "metadata": {},
+ "source": [
+ "`realize_to_dict()` has the same parameters as `realize()`, with the exception of `output_file` and `output_format`. Instead, configs can be manipulated or converted to a `dict` without the need to specify an output file or format.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 27,
+ "id": "ae7f648e-6586-4700-87e7-492ca3a02a06",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'id': '456', 'greeting': 'Hello', 'recipient': 'World'}"
+ ]
+ },
+ "execution_count": 27,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.realize_to_dict(\n",
+ " input_config={\"id\": \"456\"},\n",
+ " update_config=\"fixtures/config/get-config.yaml\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "adf2f518-f92c-4b67-a865-c01ce0b10a2e",
+ "metadata": {},
+ "source": [
+ "For more details on usage and parameters, see the Realizing Configs section above.\n",
+ ""
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python [conda env:DEV-uwtools] *",
+ "language": "python",
+ "name": "conda-env-DEV-uwtools-py"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/fixtures/config/base-config.nml b/notebooks/fixtures/config/base-config.nml
new file mode 100644
index 000000000..9f30c6505
--- /dev/null
+++ b/notebooks/fixtures/config/base-config.nml
@@ -0,0 +1,5 @@
+&memo
+ sender_id = "{{ id }}"
+ message = "{{ greeting }}, {{ recipient }}!"
+ sent = .FALSE.
+/
diff --git a/notebooks/fixtures/config/get-config.yaml b/notebooks/fixtures/config/get-config.yaml
new file mode 100644
index 000000000..be310733b
--- /dev/null
+++ b/notebooks/fixtures/config/get-config.yaml
@@ -0,0 +1,2 @@
+greeting: Hello
+recipient: World
diff --git a/notebooks/fixtures/config/keys-config.yaml b/notebooks/fixtures/config/keys-config.yaml
new file mode 100644
index 000000000..a8f524b40
--- /dev/null
+++ b/notebooks/fixtures/config/keys-config.yaml
@@ -0,0 +1,4 @@
+keys:
+ to:
+ config:
+ message: "{{ greeting }}, {{ recipient }}!"
diff --git a/notebooks/template.ipynb b/notebooks/template.ipynb
index e0e9a2e9e..39a447f70 100644
--- a/notebooks/template.ipynb
+++ b/notebooks/template.ipynb
@@ -9,7 +9,7 @@
"\n",
"The `uwtools` API's `template` module provides functions to render Jinja2 templates and to translate atparse templates to Jinja2.\n",
"\n",
- "For more information, please see the uwtools.api.template Read the Docs page."
+ "Tested on `uwtools` version 2.4.2. For more information, please see the uwtools.api.template Read the Docs page."
]
},
{
diff --git a/notebooks/tests/test_config.py b/notebooks/tests/test_config.py
new file mode 100644
index 000000000..04260206a
--- /dev/null
+++ b/notebooks/tests/test_config.py
@@ -0,0 +1,73 @@
+import yaml
+from testbook import testbook
+
+
+def test_get_config():
+ with open("fixtures/config/get-config.yaml", "r", encoding="utf-8") as f:
+ config1_str = f.read().rstrip()
+ config1_dict = yaml.safe_load(config1_str)
+ with testbook("config.ipynb", execute=True) as tb:
+ assert tb.ref("config1") == config1_dict
+ assert tb.cell_output_text(5) == config1_str
+ assert config1_str in tb.cell_output_text(7)
+ assert tb.cell_output_text(9) == "message:\n greeting: Hi\n recipient: Earth"
+
+
+def test_depth():
+ with testbook("config.ipynb", execute=True) as tb:
+ assert tb.cell_output_text(11) == "greeting=Salutations\nrecipient=Mars"
+ assert tb.cell_output_text(13) == "Cannot instantiate depth-1 SHConfig with depth-2 config"
+ assert tb.cell_output_text(15) == "[message]\ngreeting = Salutations\nrecipient = Mars"
+ assert tb.cell_output_text(17) == "Cannot instantiate depth-2 INIConfig with depth-1 config"
+
+
+def test_realize():
+ # Get config file data to compare to cell output.
+ with open("fixtures/config/get-config.yaml", "r", encoding="utf-8") as f:
+ config_str = f.read().rstrip()
+ config_dict = yaml.safe_load(config_str)
+ with open("fixtures/config/base-config.nml", "r", encoding="utf-8") as f:
+ update_config_str = f.read().rstrip()
+ with open("fixtures/config/keys-config.yaml", "r", encoding="utf-8") as f:
+ keys_config_str = f.read().rstrip()
+ with testbook("config.ipynb", execute=True) as tb:
+ with open("tmp/updated-config.nml", "r", encoding="utf-8") as f:
+ updated_config = f.read().rstrip()
+ with open("tmp/config-total.nml", "r", encoding="utf-8") as f:
+ total_config = f.read().rstrip()
+ # Ensure that cell output text matches expectations.
+ assert tb.cell_output_text(21) == str(config_dict)
+ assert tb.cell_output_text(23) == config_str
+ assert tb.cell_output_text(25) == str(config_dict)
+ for item in config_dict.items():
+ assert item[0] + "=" + item[1] in tb.cell_output_text(27)
+ assert tb.cell_output_text(29) == update_config_str
+ updated_dict = (
+ "'sender_id': '{{ id }}'",
+ "'message': 'Salutations, Mars!'",
+ "'sent': True",
+ )
+ assert all(x in tb.cell_output_text(31) for x in updated_dict)
+ assert tb.cell_output_text(33) == updated_config
+ assert tb.cell_output_text(35) == keys_config_str
+ assert tb.cell_output_text(37) == "message: Good morning, Venus!"
+ assert tb.cell_output_text(39) == update_config_str
+ expected_log = (
+ "memo.sender_id: {{ id }}",
+ "memo.message: {{ greeting }}, {{ recipient }}!",
+ )
+ assert all(x in tb.cell_output_text(41) for x in expected_log)
+ assert tb.cell_output_text(43) == update_config_str
+ assert tb.cell_output_text(45) == "Config could not be totally realized"
+ total_dict = ("'sender_id': '321'", "'message': 'Salutations, Mars!'", "'sent': True")
+ assert all(x in tb.cell_output_text(47) for x in total_dict)
+ assert tb.cell_output_text(49) == total_config
+
+
+def test_realize_to_dict():
+ with open("fixtures/config/get-config.yaml", "r", encoding="utf-8") as f:
+ config_str = f.read().rstrip()
+ with testbook("config.ipynb", execute=True) as tb:
+ assert tb.cell_output_text(51) == config_str
+ config_out = ("'id': '456'", "'greeting': 'Hello'", "'recipient': 'World'")
+ assert all(x in tb.cell_output_text(53) for x in config_out)
From 2c75b30671854b703869da4125ae3b56f1e02bf4 Mon Sep 17 00:00:00 2001
From: Travis Byrne <42480803+Byrnetp@users.noreply.github.com>
Date: Thu, 17 Oct 2024 08:37:43 -0600
Subject: [PATCH 13/24] Jupyter notebook: Config Tool Part II (#611)
* update_values() -> update_from() (#578)
* DRY out Driver._validate() (#579)
* Move execute() to its own API namespace, disambiguate (#580)
* Config classes to API (#582)
* Simplify unknown-extension error message (#584)
* UW-657 fs makedirs (#572)
* Docstring cleanup (#585)
* GH 586 bad stdin_ok prompt (#587)
* Fix issue with creating a metatask dep. (#589)
The rocoto schema is set up to allow metataskdep entries in the YAML, but the logic was not included in the tool to handle them. This addition fixes that.
* Add --show-schema support to drivers (#588)
* Config notebook p2 and example notebook removal
* removing merge diff text
* pin python version for RtD
* improvements from code review
* add config.compare() format mismatch example
* Remove typo
Co-authored-by: Emily Carpenter <137525341+elcarpenterNOAA@users.noreply.github.com>
---------
Co-authored-by: Paul Madden <136389411+maddenp-noaa@users.noreply.github.com>
Co-authored-by: Christina Holt <56881914+christinaholtNOAA@users.noreply.github.com>
Co-authored-by: Emily Carpenter <137525341+elcarpenterNOAA@users.noreply.github.com>
---
docs/environment.yml | 1 +
notebooks/config.ipynb | 911 +++++++++++++++++-
notebooks/example.ipynb | 429 ---------
notebooks/fixtures/config/alt-config.nml | 5 +
notebooks/fixtures/config/fruit-config.ini | 4 +
notebooks/fixtures/config/validate.jsonschema | 15 +
notebooks/fixtures/example/ex2-config.yaml | 3 -
.../fixtures/example/ex2-rendered-config.yaml | 3 -
notebooks/fixtures/example/ex2-values.yaml | 3 -
.../example/ex3-config-test-file-a.nml | 1 -
.../example/ex3-config-test-file-b.nml | 4 -
.../example/ex3-config-test-file-c.nml | 4 -
notebooks/tests/test_config.py | 63 ++
notebooks/tests/test_example.py | 62 --
14 files changed, 991 insertions(+), 517 deletions(-)
delete mode 100644 notebooks/example.ipynb
create mode 100644 notebooks/fixtures/config/alt-config.nml
create mode 100644 notebooks/fixtures/config/fruit-config.ini
create mode 100644 notebooks/fixtures/config/validate.jsonschema
delete mode 100644 notebooks/fixtures/example/ex2-config.yaml
delete mode 100644 notebooks/fixtures/example/ex2-rendered-config.yaml
delete mode 100644 notebooks/fixtures/example/ex2-values.yaml
delete mode 100644 notebooks/fixtures/example/ex3-config-test-file-a.nml
delete mode 100644 notebooks/fixtures/example/ex3-config-test-file-b.nml
delete mode 100644 notebooks/fixtures/example/ex3-config-test-file-c.nml
delete mode 100644 notebooks/tests/test_example.py
diff --git a/docs/environment.yml b/docs/environment.yml
index ea7494fd7..e71700ed5 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -2,6 +2,7 @@ name: readthedocs
channels:
- conda-forge
dependencies:
+ - python=3.12
- sphinx_rtd_theme=2.0.*
- sphinxcontrib-bibtex=2.6.*
- tree
diff --git a/notebooks/config.ipynb b/notebooks/config.ipynb
index bba503764..c3aca9e55 100644
--- a/notebooks/config.ipynb
+++ b/notebooks/config.ipynb
@@ -21,6 +21,13 @@
" * [Using the `values_needed` Parameter](#Using-the-values_needed-Parameter)\n",
" * [Using the `total` Parameter](#Using-the-total-Parameter)\n",
"* [Realizing Configs to a Dictionary](#Realizing-Configs-to-a-Dictionary)\n",
+ "* [Comparing Configs](#Comparing-Configs)\n",
+ "* [Validating Configs](#Validating-Configs)\n",
+ "* [Working with Config Classes](#Working-with-Config-Classes)\n",
+ " * [Comparing Config Objects](#Comparing-Config-Objects)\n",
+ " * [Rendering Values](#Rendering-Values)\n",
+ " * [Writing Configs in a Specified Format](#Writing-Configs-in-a-Specified-Format)\n",
+ " * [Updating Values](#Updating-Values) \n",
""
]
},
@@ -725,13 +732,13 @@
"name": "stderr",
"output_type": "stream",
"text": [
- "[2024-09-23T16:13:23] INFO Keys that are complete:\n",
- "[2024-09-23T16:13:23] INFO memo\n",
- "[2024-09-23T16:13:23] INFO memo.sent\n",
- "[2024-09-23T16:13:23] INFO \n",
- "[2024-09-23T16:13:23] INFO Keys with unrendered Jinja2 variables/expressions:\n",
- "[2024-09-23T16:13:23] INFO memo.sender_id: {{ id }}\n",
- "[2024-09-23T16:13:23] INFO memo.message: {{ greeting }}, {{ recipient }}!\n"
+ "[2024-10-16T15:39:13] INFO Keys that are complete:\n",
+ "[2024-10-16T15:39:13] INFO memo\n",
+ "[2024-10-16T15:39:13] INFO memo.sent\n",
+ "[2024-10-16T15:39:13] INFO \n",
+ "[2024-10-16T15:39:13] INFO Keys with unrendered Jinja2 variables/expressions:\n",
+ "[2024-10-16T15:39:13] INFO memo.sender_id: {{ id }}\n",
+ "[2024-10-16T15:39:13] INFO memo.message: {{ greeting }}, {{ recipient }}!\n"
]
},
{
@@ -964,12 +971,900 @@
},
{
"cell_type": "markdown",
- "id": "adf2f518-f92c-4b67-a865-c01ce0b10a2e",
+ "id": "be66b870-a42c-46f2-a045-b769354403de",
"metadata": {},
"source": [
"For more details on usage and parameters, see the Realizing Configs section above.\n",
+ "\n",
+ "## Comparing Configs\n",
+ "\n",
+ "The `config` tool can be used to compare two configuration files using `config.compare()`.\n",
""
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "id": "249ae7a9-1ad3-4401-98b0-bc3c433f22f4",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function compare in module uwtools.api.config:\n",
+ "\n",
+ "compare(config_1_path: Union[pathlib.Path, str], config_2_path: Union[pathlib.Path, str], config_1_format: Optional[str] = None, config_2_format: Optional[str] = None) -> bool\n",
+ " Compare two config files.\n",
+ "\n",
+ " Recognized file extensions are: ini, nml, sh, yaml\n",
+ "\n",
+ " :param config_1_path: Path to 1st config file.\n",
+ " :param config_2_path: Path to 2nd config file.\n",
+ " :param config_1_format: Format of 1st config file (optional if file's extension is recognized).\n",
+ " :param config_2_format: Format of 2nd config file (optional if file's extension is recognized).\n",
+ " :return: ``False`` if config files had differences, otherwise ``True``.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(config.compare)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "908b17fe-ae2d-4964-a659-0481d063c037",
+ "metadata": {},
+ "source": [
+ "Consider the following config files, which have similar values, with the exception of `sent`'s value.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 29,
+ "id": "0218e5de-2c25-4d7a-a7b6-0f05ad81afb2",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "&memo\n",
+ " sender_id = \"{{ id }}\"\n",
+ " message = \"{{ greeting }}, {{ recipient }}!\"\n",
+ " sent = .FALSE.\n",
+ "/\n",
+ "----------------------------------------------\n",
+ "&memo\n",
+ " sender_id = \"{{ id }}\"\n",
+ " message = \"{{ greeting }}, {{ recipient }}!\"\n",
+ " sent = .TRUE.\n",
+ "/\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/base-config.nml\n",
+ "echo ----------------------------------------------\n",
+ "cat fixtures/config/alt-config.nml "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c4361a7c-9c16-4bc0-b3e6-985d85f1450c",
+ "metadata": {},
+ "source": [
+ "`compare()` returns `True` if the configs contain identical key-value pairs, and `False` otherwise. If a logger has been initialized, information is logged on which files are being compared and the values that differ, if any. Files are passed to `config_1_path` and `config_2_path` as a string filename or Path object. Corresponding optional formats may be passed using `config_1_format` and `config_2_format` and are only needed if the format suffix is not recognized.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 30,
+ "id": "988b72bc-7983-4d24-9d37-c9ba32a31ac4",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-10-16T15:39:13] INFO - fixtures/config/base-config.nml\n",
+ "[2024-10-16T15:39:13] INFO + fixtures/config/alt-config.nml\n",
+ "[2024-10-16T15:39:13] INFO ---------------------------------------------------------------------\n",
+ "[2024-10-16T15:39:13] INFO memo: sent: - False + True\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 30,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.compare(\n",
+ " config_1_path=Path('fixtures/config/base-config.nml'),\n",
+ " config_2_path='fixtures/config/alt-config.nml',\n",
+ " config_1_format='nml',\n",
+ " config_2_format='nml'\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "655717b7-b7db-4711-9dc4-c00557e6c2df",
+ "metadata": {},
+ "source": [
+ "To see the behavior of `compare()` when key-value pairs are identical, one of the configs from above is copied in the cell below.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 31,
+ "id": "ceb7f893-398b-41e4-818a-49cd036c2bfe",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%%bash\n",
+ "cp fixtures/config/base-config.nml tmp/config-copy.nml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "02c75cf7-9f24-44a0-97c6-abd7b44fb60f",
+ "metadata": {},
+ "source": [
+ "When these two files are compared, `True` is returned and the log reports no differences.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 32,
+ "id": "8b495735-2396-436e-87db-d886ac6769fa",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-10-16T15:39:13] INFO - fixtures/config/base-config.nml\n",
+ "[2024-10-16T15:39:13] INFO + tmp/config-copy.nml\n",
+ "[2024-10-16T15:39:13] INFO ---------------------------------------------------------------------\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 32,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.compare(\n",
+ " config_1_path='fixtures/config/base-config.nml',\n",
+ " config_2_path='tmp/config-copy.nml',\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "eb4536f8-d198-46f8-ac74-ee9d2010704e",
+ "metadata": {},
+ "source": [
+ "If a comparison is attempted between two files whose formats that don't match, `compare()` returns `False` and the mismatch is reported.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 33,
+ "id": "438ccd5b-30d5-49b4-ba12-6e6e89f46d28",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-10-16T15:39:13] ERROR Formats do not match: yaml vs nml\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 33,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.compare(\n",
+ " config_1_path=Path('fixtures/config/get-config.yaml'),\n",
+ " config_2_path=Path('fixtures/config/base-config.nml')\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e14c0158-baa4-45d0-a86c-d9a245021229",
+ "metadata": {},
+ "source": [
+ "## Validating Configs\n",
+ "\n",
+ "The `config.validate()` function checks if a given config conforms to a specified JSON schema.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 34,
+ "id": "2eb5b2ce-bebd-449d-90a5-764484aa03aa",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on function validate in module uwtools.api.config:\n",
+ "\n",
+ "validate(schema_file: Union[pathlib.Path, str], config: Union[dict, str, uwtools.config.formats.yaml.YAMLConfig, pathlib.Path, NoneType] = None, stdin_ok: bool = False) -> bool\n",
+ " Check whether the specified config conforms to the specified JSON Schema spec.\n",
+ "\n",
+ " If no config is specified, ``stdin`` is read and will be parsed as YAML and then validated. A\n",
+ " ``dict`` or a YAMLConfig instance may also be provided for validation.\n",
+ "\n",
+ " :param schema_file: The JSON Schema file to use for validation.\n",
+ " :param config: The config to validate.\n",
+ " :param stdin_ok: OK to read from ``stdin``?\n",
+ " :return: ``True`` if the YAML file conforms to the schema, ``False`` otherwise.\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "help(config.validate)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "57cdde4c-b3ab-40b1-8e52-1f99ea6d9696",
+ "metadata": {},
+ "source": [
+ "Consider the simple YAML config below. `validate()` used together with an appropriate JSON schema ensures that the config meets expectations before it's used elsewhere.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 35,
+ "id": "dc7d2b80-ea92-4301-ae85-4edbf61bf510",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "greeting: Hello\n",
+ "recipient: World\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/get-config.yaml"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1eeca5ad-e969-4670-bddd-8e20b38f3d6c",
+ "metadata": {},
+ "source": [
+ "Below is an example of a schema used to validate a config. It ensures that the required keys are present and the value types match expectations. For information on the keys used here and more, please refer to JSON Schema documentation.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 36,
+ "id": "f9c66a24-2a7e-43be-839f-3ad5b136b646",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "{\n",
+ " \"additionalProperties\": false,\n",
+ " \"properties\": {\n",
+ " \"greeting\": {\n",
+ " \"type\": \"string\"\n",
+ " },\n",
+ " \"recipient\": {\n",
+ " \"type\": \"string\"\n",
+ " }\n",
+ " },\n",
+ " \"required\": [\n",
+ " \"greeting\", \"recipient\"\n",
+ " ],\n",
+ " \"type\": \"object\"\n",
+ "}\n"
+ ]
+ }
+ ],
+ "source": [
+ "%%bash\n",
+ "cat fixtures/config/validate.jsonschema"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8c61a2d2-473c-45c6-9c6c-6c07fc5bf940",
+ "metadata": {},
+ "source": [
+ "The schema file and config from above are passed to the respective `schema_file` and `config` parameters. Config file paths should be passed as a string or Path object. Files should be of YAML format, or parseable as YAML. Alternatively, a `YAMLConfig` object or a Python `dict` can be provided. `validate()` returns `True` if the config conforms to the JSON schema, and `False` otherwise. With a logger initialized, details about any validation errors are reported.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 37,
+ "id": "f3873050-d857-490d-aeeb-f9217a7f808c",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-10-16T15:39:13] INFO 0 UW schema-validation errors found\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 37,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.validate(\n",
+ " schema_file='fixtures/config/validate.jsonschema',\n",
+ " config='fixtures/config/get-config.yaml'\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8d151205-4a95-4b46-aa1d-30ec30d96e88",
+ "metadata": {},
+ "source": [
+ "The `config` argument also accepts a dictionary. In the next example, validation errors exist, and the logger reports the number of errors found along with their locations and details.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 38,
+ "id": "b5664e58-8ddc-438c-8180-1e2911838744",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[2024-10-16T15:39:13] ERROR 1 UW schema-validation error found\n",
+ "[2024-10-16T15:39:13] ERROR Error at recipient:\n",
+ "[2024-10-16T15:39:13] ERROR 47 is not of type 'string'\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "False"
+ ]
+ },
+ "execution_count": 38,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "config.validate(\n",
+ " schema_file='fixtures/config/validate.jsonschema',\n",
+ " config={'greeting':'Hello', 'recipient':47}\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fffeb768-7109-472f-916b-1407520b0f5e",
+ "metadata": {},
+ "source": [
+ "## Working with Config Classes\n",
+ "\n",
+ "The `config` tool provides five classes that can be used to work with configs in an object-oriented way. The five different classes each work with a single format: `config.FieldTableConfig`, `config.INIConfig`, `config.NMLConfig`, `config.SHConfig`, and `config.YAMLConfig`. `config.INIConfig` is demonstrated here, but the other classes all use methods of the same names for working with each respective format.\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 39,
+ "id": "6f4df804-cda2-48c4-bf42-a5b57de5e066",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Help on class INIConfig in module uwtools.config.formats.ini:\n",
+ "\n",
+ "class INIConfig(uwtools.config.formats.base.Config)\n",
+ " | INIConfig(config: Union[dict, pathlib.Path, NoneType] = None)\n",
+ " |\n",
+ " | Work with INI configs.\n",
+ " |\n",
+ " | Method resolution order:\n",
+ " | INIConfig\n",
+ " | uwtools.config.formats.base.Config\n",
+ " | abc.ABC\n",
+ " | collections.UserDict\n",
+ " | collections.abc.MutableMapping\n",
+ " | collections.abc.Mapping\n",
+ " | collections.abc.Collection\n",
+ " | collections.abc.Sized\n",
+ " | collections.abc.Iterable\n",
+ " | collections.abc.Container\n",
+ " | builtins.object\n",
+ " |\n",
+ " | Methods defined here:\n",
+ " |\n",
+ " | __init__(self, config: Union[dict, pathlib.Path, NoneType] = None)\n",
+ " | :param config: Config file to load (None => read from stdin), or initial dict.\n",
+ " |\n",
+ " | dump(self, path: Optional[pathlib.Path] = None) -> None\n",
+ " | Dump the config in INI format.\n",
+ " |\n",
+ " | :param path: Path to dump config to (default: stdout).\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Class methods defined here:\n",
+ " |\n",
+ " | dump_dict(cfg: dict, path: Optional[pathlib.Path] = None) -> None\n",
+ " | Dump a provided config dictionary in INI format.\n",
+ " |\n",
+ " | :param cfg: The in-memory config object to dump.\n",
+ " | :param path: Path to dump config to (default: stdout).\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data and other attributes defined here:\n",
+ " |\n",
+ " | __abstractmethods__ = frozenset()\n",
+ " |\n",
+ " | __annotations__ = {}\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from uwtools.config.formats.base.Config:\n",
+ " |\n",
+ " | __repr__(self) -> str\n",
+ " | Return the string representation of a Config object.\n",
+ " |\n",
+ " | compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool\n",
+ " | Compare two config dictionaries.\n",
+ " |\n",
+ " | Assumes a section/key/value structure.\n",
+ " |\n",
+ " | :param dict1: The first dictionary.\n",
+ " | :param dict2: The second dictionary (default: this config).\n",
+ " | :return: True if the configs are identical, False otherwise.\n",
+ " |\n",
+ " | dereference(self, context: Optional[dict] = None) -> None\n",
+ " | Render as much Jinja2 syntax as possible.\n",
+ " |\n",
+ " | update_from(self, src: Union[dict, collections.UserDict]) -> None\n",
+ " | Update a config.\n",
+ " |\n",
+ " | :param src: The dictionary with new data to use.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Data descriptors inherited from uwtools.config.formats.base.Config:\n",
+ " |\n",
+ " | __dict__\n",
+ " | dictionary for instance variables\n",
+ " |\n",
+ " | __weakref__\n",
+ " | list of weak references to the object\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from collections.UserDict:\n",
+ " |\n",
+ " | __contains__(self, key)\n",
+ " | # Modify __contains__ and get() to work like dict\n",
+ " | # does when __missing__ is present.\n",
+ " |\n",
+ " | __copy__(self)\n",
+ " |\n",
+ " | __delitem__(self, key)\n",
+ " |\n",
+ " | __getitem__(self, key)\n",
+ " |\n",
+ " | __ior__(self, other)\n",
+ " |\n",
+ " | __iter__(self)\n",
+ " |\n",
+ " | __len__(self)\n",
+ " |\n",
+ " | __or__(self, other)\n",
+ " | Return self|value.\n",
+ " |\n",
+ " | __ror__(self, other)\n",
+ " | Return value|self.\n",
+ " |\n",
+ " | __setitem__(self, key, item)\n",
+ " |\n",
+ " | copy(self)\n",
+ " |\n",
+ " | get(self, key, default=None)\n",
+ " | D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Class methods inherited from collections.UserDict:\n",
+ " |\n",
+ " | fromkeys(iterable, value=None)\n",
+ " |\n",
+ " | ----------------------------------------------------------------------\n",
+ " | Methods inherited from collections.abc.MutableMapping:\n",
+ " |\n",
+ " | clear(self)\n",
+ " | D.clear() -> None. Remove all items from D.\n",
+ " |\n",
+ " | pop(self, key, default=