Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0dc8622
#697 Add fre workflow checkout tool and test
Jan 27, 2026
099342f
#697 Comment out non-existent tools for now
Jan 27, 2026
40d43a5
#697 Comment out things that aren't done yet
Feb 6, 2026
440a040
#697 Add validation and update output file name for resolved yaml
Feb 6, 2026
3249384
#697 Add assert to test
Feb 6, 2026
7c96c60
Add comma
singhd789 Feb 11, 2026
125b5e8
#697 Merge branch 'main' of github.com:NOAA-GFDL/fre-cli into 697.wo…
Feb 12, 2026
b217d5e
#697 Add comma
Feb 12, 2026
0694409
#697 Merge branch '697.workflow-checkout-tool' of github.com:NOAA-GF…
Feb 12, 2026
13ca73c
#697 Put file moving in checkout creation
Feb 12, 2026
70171a6
#697 See if pipeline run as root
Feb 12, 2026
c558027
#697 Fix stat
Feb 12, 2026
60e73cc
#697 make it fail for a sec
Feb 12, 2026
38accb6
test failure
Feb 18, 2026
06750f2
#697 Add some readme content
Feb 18, 2026
ecb3ed3
#697 Update comment
Feb 18, 2026
32d5906
#697 Remove click branch overwrite
Feb 18, 2026
881366e
#697 Comment out use of branch override
Feb 18, 2026
8bef3ee
#697 Fix file permission test
Feb 18, 2026
7d1eb4e
#697 Fix test
Feb 18, 2026
516cf5d
#697 hopfeully change file permissions correctly this time
Feb 18, 2026
95e25d8
#697 Nix file permission changes - doesn't work when running as root
Feb 18, 2026
e3c2452
#697 Address pylint messages
Feb 18, 2026
7b1f816
#697 Add fre workflow cli tests
Feb 18, 2026
c48728b
#697 Some clean up
Feb 19, 2026
817bf7a
#697 Add --force-checkout and --target-dir
Feb 20, 2026
09afc2d
#697 Update spacing to account for warning output
Feb 20, 2026
a29645a
#697 Update documentation
Feb 20, 2026
560093c
#697 Update doc
Feb 20, 2026
10ce009
#697 Fix tests
Feb 20, 2026
61be15c
#697 Fix --target-dir click value
Feb 20, 2026
04e8985
#697 Add default dir (see if pipeline passes)
Feb 20, 2026
9daa391
#697 update
Feb 20, 2026
14e87e9
#697 update
Feb 20, 2026
7af0425
#697 Fix tests and update argument order
Feb 25, 2026
c235bb4
#697 Merge branch 'main' of github.com:NOAA-GFDL/fre-cli into 697.wo…
Feb 25, 2026
5ee7f19
#697 fix exit code
Feb 25, 2026
007696d
#697 Adjust spacing
Feb 25, 2026
9ab07ca
#697 right spacing?
Feb 25, 2026
2d26bce
#697 set TMPDIR
Feb 25, 2026
1af0992
#697 Change warning to info
Feb 25, 2026
14c0d20
#697 Update help message and other output
Feb 25, 2026
ef0377b
#697 Uncomment validation
Feb 25, 2026
779b6ba
#697 Address ians comments
Feb 26, 2026
6ce4930
#697 Update
Feb 26, 2026
0bd5f60
#697 Update yaml workflow addition
Mar 5, 2026
e542b60
#697 Update readme
Mar 5, 2026
b1f4664
#697 Some documentation updates
Mar 9, 2026
fd672d3
#697 change repo to repository
Mar 9, 2026
98fafcb
#697 change repo to repository
Mar 9, 2026
7ad2290
#697 update test
Mar 9, 2026
f62089f
#697 Change `fre_logger.warning` to `fre_logger.info`
Mar 9, 2026
361f65f
#697 Remove envvar TMPDIR usage as default for now
Mar 9, 2026
a5e9314
#697 update doc
Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

fre_logger = logging.getLogger(__name__)

FORMAT = "[%(levelname)5s:%(filename)24s:%(funcName)24s] %(message)s"
FORMAT = "[%(levelname)7s:%(filename)24s:%(funcName)24s] %(message)s"
logging.basicConfig(level = logging.WARNING,
format = FORMAT,
filename = None,
Expand Down
3 changes: 2 additions & 1 deletion fre/fre.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
# click and lazy group loading
@click.group(
cls = LazyGroup,
lazy_subcommands = {"pp": ".pp.frepp.pp_cli",
lazy_subcommands = {"workflow": ".workflow.freworkflow.workflow_cli",
"pp": ".pp.frepp.pp_cli",
"catalog": ".catalog.frecatalog.catalog_cli",
"list": ".list_.frelist.list_cli",
"check": ".check.frecheck.check_cli",
Expand Down
6 changes: 3 additions & 3 deletions fre/tests/test_fre_cmor_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ def test_cli_fre_cmor_help_and_debuglog():
assert result.exit_code == 0
assert Path("TEST_FOO_LOG.log").exists()

log_text_line_1='[ INFO: fre.py: fre] fre_file_handler added to base_fre_logger\n' # pylint: disable=line-too-long
log_text_line_2='[DEBUG: fre.py: fre] click entry-point function call done.\n' # pylint: disable=line-too-long
log_text_line_1='[ INFO: fre.py: fre] fre_file_handler added to base_fre_logger\n' # pylint: disable=line-too-long
log_text_line_2='[ DEBUG: fre.py: fre] click entry-point function call done.\n' # pylint: disable=line-too-long
with open( "TEST_FOO_LOG.log", 'r', encoding='utf-8') as log_text:
line_list=log_text.readlines()
assert log_text_line_1 in line_list[0]
Expand All @@ -89,7 +89,7 @@ def test_cli_fre_cmor_help_and_infolog():
assert result.exit_code == 0
assert Path("TEST_FOO_LOG.log").exists()

log_text_line_1='[ INFO: fre.py: fre] fre_file_handler added to base_fre_logger\n' # pylint: disable=line-too-long
log_text_line_1='[ INFO: fre.py: fre] fre_file_handler added to base_fre_logger\n' # pylint: disable=line-too-long
with open( "TEST_FOO_LOG.log", 'r', encoding='utf-8') as log_text:
line_list=log_text.readlines()
assert log_text_line_1 in line_list[0]
Expand Down
103 changes: 103 additions & 0 deletions fre/tests/test_fre_workflow_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
CLI Tests for fre workflow *
Tests the command-line-interface calls for tools in the fre workflow suite.

Each tool generally gets 3 tests:
- fre workflow $tool, checking for exit code 0 (fails if cli isn't configured right)
- fre workflow $tool --help, checking for exit code 0 (fails if the code doesn't run)
- fre workflow $tool --optionDNE, checking for exit code 2; misuse of command (fails if cli isn't configured
right and thinks the tool has a --optionDNE option)
"""
import os
from pathlib import Path
from click.testing import CliRunner
from fre import fre

runner = CliRunner()

## fre workflow subtools search for if TMPDIR is set, specifically for fre workflow checkout --target-dir
# If a value for --target-dir is not passed --> TMPDIR will be used for --target-dir
# If TMPDIR is not set --> a default location will be used for --target-dir
#-- fre workflow
def test_cli_fre_workflow(monkeypatch, tmp_path):
''' fre workflow '''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow"])
assert result.exit_code == 2

def test_cli_fre_workflow_help(monkeypatch, tmp_path):
''' fre workflow --help '''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow", "--help"])
assert result.exit_code == 0

def test_cli_fre_workflow_opt_dne(monkeypatch, tmp_path):
''' fre workflow optionDNE '''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow", "optionDNE"])
assert result.exit_code == 2

#-- fre workflow checkout
def test_cli_fre_workflow_checkout(monkeypatch, tmp_path):
''' fre workflow checkout'''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow", "checkout"])
assert result.exit_code == 2

def test_cli_fre_workflow_checkout_help(monkeypatch, tmp_path):
''' fre workflow checkout --help '''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow", "checkout", "--help"])
assert result.exit_code == 0

def test_cli_fre_workflow_checkout_opt_dne(monkeypatch, tmp_path):
''' fre workflow checkout optionDNE '''
monkeypatch.setenv("TMPDIR", tmp_path)
result = runner.invoke(fre.fre, args=["workflow", "checkout", "optionDNE"])
assert result.exit_code == 2

def test_cli_fre_workflow_checkout_target_dir_set(tmp_path):
"""
Test checkout in target directory if --target-dir is explicitly set.
"""
experiment = "c96L65_am5f7b12r1_amip_TESTING"
result = runner.invoke(fre.fre, args=["workflow", "checkout",
"--yamlfile", "fre/workflow/tests/AM5_example/am5.yaml",
"--experiment", experiment,
"--application", "pp",
"--target-dir", tmp_path])
assert result.exit_code == 0
assert Path(f"{tmp_path}/cylc-src/{experiment}").exists()

def test_cli_fre_workflow_checkout_TMPDIR_set(tmp_path, monkeypatch):
"""
Test checkout if TMPDIR environment variable is set and --target-dir has no
specified value
"""
experiment = "c96L65_am5f7b12r1_amip_TESTING"
Path(f"{tmp_path}/env_var").mkdir(parents=True)
monkeypatch.setenv("TMPDIR", f"{tmp_path}/env_var")

result = runner.invoke(fre.fre, args=["workflow", "checkout",
"-y", "fre/workflow/tests/AM5_example/am5.yaml",
"-e", experiment,
"-a", "pp"])
assert result.exit_code == 0
assert Path(f"{os.environ['TMPDIR']}/cylc-src/{experiment}").exists()

#def test_cli_fre_workflow_checkout_default_dir():
# """
# Test checkout if TMPDIR and --target-dir is not set;
# use the default location: ~/.fre
# """
experiment = "c96L65_am5f7b12r1_amip_TESTING"
monkeypatch.setenv("TMPDIR", "")

result = runner.invoke(fre.fre, args=["workflow", "checkout",
"-y", "fre/workflow/tests/AM5_example/am5.yaml",
"-e", experiment,
"-a", "pp"])
#default cylc-src location
default_dir = os.path.expanduser("~/.fre")
assert result.exit_code == 0
assert Path(f"{default_dir}/cylc-src/{experiment}").exists()
19 changes: 19 additions & 0 deletions fre/workflow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# FRE workflow
`fre workflow` provides subtools that help to clone, install, and run a workflow from a repository.

## Quickstart
From the root of the fre-cli repository, run:
```
# Checkout/clone the post-processing workflow repository
fre workflow checkout -y fre/workflow/tests/AM5_example/am5.yaml -e c96L65_am5f7b12r1_amip_TESTING -a pp
```

## Subtools
- `fre workflow checkout [options]`
- Purpose: Clone the workflow repository/branch, depending on the application passed.
- Options:
- `-y, --yamlfile [model yaml] (str; required)`
- `-e, --experiment [experiment name] (str; required)`
- `-a, --application [ run | pp ] (str; required)`
- `--target-dir [target directory where workflow will be cloned] (str; optional; default is ~/.fre-workflows`
- `--force-checkout (bool; optional)`
Empty file added fre/workflow/__init__.py
Empty file.
159 changes: 159 additions & 0 deletions fre/workflow/checkout_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
""" Workflow checkout """
import os
import subprocess
from pathlib import Path
import logging
import shutil
import json
from jsonschema import validate, SchemaError, ValidationError
from typing import Optional

import fre.yamltools.combine_yamls_script as cy
from fre.app.helpers import change_directory

fre_logger = logging.getLogger(__name__)

######VALIDATE#####
def validate_yaml(yamlfile: dict, application: str):
"""
Validate the format of the yaml file based
on the schema.json held in [gfdl_msd_schemas](https://github.com/NOAA-GFDL/gfdl_msd_schemas).

:param yamlfile: Model, settings, pp, and analysis yaml
information combined into a dictionary
:type yamlfile: dict
:param application: type of workflow to check out/clone
:type application: string
:raises ValueError:
- if gfdl_mdf_schema path is not valid
- combined yaml is not valid
- unclear error in validation
"""
schema_dir = Path(__file__).resolve().parents[1]
schema_path = os.path.join(schema_dir, 'gfdl_msd_schemas', 'FRE', f'fre_{application}.json')
fre_logger.info("Using yaml schema '%s'", schema_path)
# Load the json schema: .load() (vs .loads()) reads and parses the json in one)
try:
with open(schema_path,'r', encoding='utf-8') as s:
schema = json.load(s)
except:
fre_logger.error("Schema '%s' is not valid. Contact the FRE team.", schema_path)
raise

# Validate yaml
# If the yaml is not valid, the schema validation will raise errors and exit
try:
validate(instance = yamlfile,schema=schema)
fre_logger.info(" ** COMBINED YAML VALID ** ")
except SchemaError as exc:
raise ValueError(f"Schema '{schema_path}' is not valid. Contact the FRE team.") from exc
except ValidationError as exc:
raise ValueError("Combined yaml is not valid. Please fix the errors and try again.") from exc
except Exception as exc:
raise ValueError("Unclear error from validation. Please try to find the error and try again.") from exc

def workflow_checkout(target_dir: str, yamlfile: str = None, experiment: str = None, application: str = None, force_checkout: Optional[bool] = False):
"""
Create a directory and clone the workflow template files from a defined repository.

:param yamlfile: Model yaml configuration file
:type yamlfile: str
:param experiment: One of the postprocessing experiment names from the
yaml displayed by fre list exps -y $yamlfile
(e.g. c96L65_am5f4b4r0_amip), default None
:type experiment: str
:param application: Which workflow will be used/cloned
:type application: str
:param target_dir: Target/base directory used for cylc-src/<workflow> creation
:type target_dir: str
:param force_checkout: re-clone the workflow repository if it exists
:type force_checkout: bool
:raises OSError: if the checkout script was not able to be created
:raises ValueError:
- if the repository and/or tag was not defined
- if the target directory does not exist or cannot be found
- if neither tag nor branch matches the git clone branch arg
"""
# Used in consolidate_yamls function for now
platform = None
target = None

if application in ["run", "pp"]:
fre_logger.info(" ** Configuring the resolved YAML for the %s **", application)
yaml = cy.consolidate_yamls(yamlfile=yamlfile,
experiment=experiment,
platform=platform,
target=target,
use=application,
output="config.yaml")

validate_yaml(yamlfile = yaml, application = application)

# Reset application for pp to make it discoverable in yaml config
if application == "pp":
application = "postprocess"

workflow_info = yaml.get(application).get("workflow")

repo = workflow_info.get("repository")
tag = workflow_info.get("version")
fre_logger.info("Defined tag ==> '%s'", tag)

if None in [repo, tag]:
raise ValueError(f"One of these are None: repo / tag = {repo} / {tag}")

fre_logger.info("(%s):(%s) check out for %s ==> REQUESTED", repo, tag, application)

# Create src_dir if it does not exist
if not Path(target_dir).exists():
Path(target_dir).mkdir(parents=True, exist_ok=True)

# Define cylc-src directory
src_dir = f"{target_dir}/cylc-src"
# workflow name
workflow_name = experiment

# create workflow in cylc-src
try:
Path(src_dir).mkdir(parents=True, exist_ok=True)
except Exception as exc:
raise OSError(
f"(checkoutScript) directory {src_dir} wasn't able to be created. exit!") from exc

if Path(f"{src_dir}/{workflow_name}").is_dir():
fre_logger.info(" *** PREVIOUS CHECKOUT FOUND: %s/%s *** ", src_dir, workflow_name)
if force_checkout:
fre_logger.warning(" *** REMOVING %s/%s *** ", src_dir, workflow_name)
shutil.rmtree(f"{src_dir}/{workflow_name}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hoarder in me is hesitant about "rm -rf"s. How about renaming it with a date stamp instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, valid point. I'm just worried the checkout directory might get flooded with workflows if the user forgets to remove them

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pathway will only exist if the checked-out location is now inconsistent with the fre workflow checkout instructions.

Normally, the checked-out workflow template will be completely satisfactory, and unchanged since the last call.

So I think in general, the old cylc-src locations won't accumulate too much, and if they do, well, users can delete it themselves or we can change our mind.

Copy link
Contributor Author

@singhd789 singhd789 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That almost makes me want to go back to the the default cylc-src location clearly being HOME though - so it will be more discoverable than ~/.fre-workflows. I mean, --force-checkout isn't required still. So if there is a checked out template that exists, that is unchanged AND --force-checkout is not used, that existing checkout will be used.

The way I see it, --force-checkout is optional and will probably mainly be used if the user wants it gone, right?

else:
with change_directory(f"{src_dir}/{workflow_name}"):
# capture the branch and tag
# if either match git_clone_branch_arg, then success. otherwise, fail.
current_tag = subprocess.run(["git","describe","--tags"],
capture_output = True,
text = True, check = True).stdout.strip()
current_branch = subprocess.run(["git", "branch", "--show-current"],
capture_output = True,
text = True, check = True).stdout.strip()

if tag in (current_tag, current_branch):
fre_logger.warning("Checkout exists ('%s/%s'), and matches '%s'", src_dir, workflow_name, tag)
else:
fre_logger.error(
"ERROR: Checkout exists ('%s/%s') and does not match '%s'", src_dir, workflow_name, tag)
fre_logger.error(
"ERROR: Current branch: '%s', Current tag-describe: '%s'", current_branch, current_tag)
raise ValueError('Neither tag nor branch matches the git clone branch arg')
if not Path(f"{src_dir}/{workflow_name}").is_dir():
fre_logger.info("Workflow does not exist; will create now")
clone_output = subprocess.run( ["git", "clone","--recursive",
f"--branch={tag}",
repo, f"{src_dir}/{workflow_name}"],
capture_output = True, text = True, check = True)
fre_logger.debug(clone_output)
fre_logger.info("(%s):(%s) check out ==> SUCCESSFUL", repo, tag)

## Move combined yaml to cylc-src location
current_dir = Path.cwd()
shutil.move(Path(f"{current_dir}/config.yaml"), f"{src_dir}/{workflow_name}")
fre_logger.info("Combined yaml file moved to %s/%s", src_dir, workflow_name)
41 changes: 41 additions & 0 deletions fre/workflow/freworkflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
''' fre workflow click interface for fre workflow subcommands'''
import os
import click
import logging
fre_logger = logging.getLogger(__name__)

#fre tools
from . import checkout_script
#from . import install_script
#from . import run_script

@click.group(help=click.style(" - workflow subcommands", fg=(57,139,210)))
def workflow_cli():
''' entry point to fre workflow click commands '''

@workflow_cli.command()
@click.option("-y", "--yamlfile", type=str,
help="Model yaml file",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this comment to start an issue to propose an idea I have:
All(?) of the fre tools require the model yaml as an argument. We could define one common help message in a common module and import that module in the fre tool click interface modules. Something like:
fre/utils.py

YAMLFILE_OPT_HELP = """Model configuration yaml FILENAME."""

and then we import utils.py and use the variable utils.YAMLFILE_OPT_HELP

And this utils.py would define more things that are common - beginning with the common click interface help messages.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if y'all don't mind me chiming in here: I like the idea of isolating const style values/variables in a single spot for reuse. It also explicitly exposes any necessary hard-config to future developers and scientific users. I did this recently with fre.cmor.

... BUT, i think it should be in a file named something other than utils.py. the term utils or e.g. libs often implies to someone like me, helper/shared functions. Something more specific, fre/consts.py would work, but someone should make sure consts isn't a reserved word for something in the python standard library.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required=True)
@click.option("-e", "--experiment", type=str,
help="Experiment name",
required=True)
@click.option("-a", "--application",
type=click.Choice(['run', 'pp']),
help="Type of workflow to check out/clone",
required=True)
@click.option("--target-dir",
type=str,
default=os.path.expanduser("~/.fre-workflows"),
help=f"""Target directory for the workflow to be cloned into.
If not defined, a default location of ~/.fre-workflows
will be used""")
@click.option("--force-checkout",
is_flag=True,
default=False,
help="If the checkout already, exists, remove and clone the desired repo again.")
def checkout(target_dir, yamlfile, experiment, application, force_checkout):
"""
Checkout/extract fre workflow
"""
checkout_script.workflow_checkout(target_dir, yamlfile, experiment, application, force_checkout)
Loading
Loading