Configure workflow scripts#
Separating configuration from executable code enables users to define workflows without needing to constantly update the underlying source code. By providing parameters such as input directories, parameter values, and other instructions within an external text file, one can create a customized execution plan without the need for duplicating workflow files. This approach transforms the software into a versatile engine where the configuration file acts as the primary interface for defining what the workflow should execute.
The primary advantage of using external configuration files is the ability to maintain a clear, portable record of specific analysis and project settings. This confers three major benefits:
Reproducibility: Each configuration file serves as a documented history of the exact settings used for a specific run, which is essential for auditing and consistent results across different end-users.
Flexibility: Multiple versions of a configuration file can test different scenarios or process separate datasets without needing to recreate the entire workflow from scratch.
Accessibility: This design enables users to modify program behavior by simply editing a text file, which removes the requirement to navigate or understand every component of the underlying script structure.
Configuration formats#
Configuration files can be defined using a variety of file formats including JSON, YAML, TOML, INI, and even plain TXT. These structured data standards allow for the specification of operational parameters within a portable, text-based file that exists independently of the core Python package. Selecting the format that best aligns with specific technical requirements facilitates the creation of a clear and repeatable set of instructions for the software.
Choosing the right file format
Selection of the appropriate format depends on the complexity of the workflow and the primary method of interaction. For workflows requiring frequent manual updates, YAML is often preferred due to support for descriptive comments and a clean visual hierarchy. Conversely, if a configuration is generated or consumed by automated tools and web services, JSON provides a more rigid and widely compatible structure. Unstructured formats like TXT should be avoided unless the workflow requires only a single, simple value, as these files lack the built-in validation and nesting capabilities provided by established standards.
While various standards exist for data serialization, this guide focuses on JSON and YAML due to their extensive library support and common usage in Python-based workflow automation.
JSON#
JSON, or JavaScript Object Notation, is a lightweight data interchange format widely used for web services and automated systems. It utilizes a strict structure of brackets and braces to organize information into key-value pairs and lists.
Visual Style: Brackets and Braces
Comments: Not Supported
Data Relationships: Highly structured nested objects
Primary Use: APIs and automated data interchange
Due to this rigid syntax, JSON is highly efficient for machine processing and ensures that configuration data remains consistent across different platforms.
YAML#
YAML, or YAML Ain’t Markup Language, is a human-friendly data serialization standard designed for readability. It relies on indentation and whitespace rather than special characters to define the hierarchy of a workflow.
Visual Style: Indentation and Whitespace
Comments: Supported (using the # symbol)
Data Relationships: Visual hierarchy through indentation
Primary Use: Manual configurations and setup files
This format is particularly popular for manual configuration because it supports internal comments, allowing for the documentation of specific settings directly within the file.
Modifying workflows#
Say that we have the following example workflow in a file called example_biodata_ingestion_workflow.py. This section only targets the biodata setup and ingestion. The goal is to externalize the parameters that users change most often, while leaving the rest of the workflow untouched.
This block contains many values that users commonly edit between runs, including data paths, sheet mappings, ship and survey identifiers, species filters, and column mapping dictionaries. Moving those values into a configuration file reduces copy paste edits and makes each run definition easier to review and reproduce.
{
"data_root": "C:/Data/EchopopData",
"biodata_file": "survey_biodata.xlsx",
"biodata_sheets": {
"catch": "biodata_catch",
"length": "biodata_length",
"specimen": "biodata_specimen"
},
"biodata_subset": {
"species_id": 22500,
"ship_us": 160,
"ship_can": 584,
"survey_us": 201906,
"survey_can": 2019097,
"can_haul_offset": 200,
},
"rename_biodata_columns": {
"frequency": "length_count",
"haul": "haul_num",
"haul ": "haul_num",
"weight_in_haul": "weight"
}
}
data_root: C:/Data/EchopopData
biodata_file: survey_biodata.xlsx
biodata_sheets:
catch: biodata_catch
length: biodata_length
specimen: biodata_specimen
biodata_subset:
species_id: 22500
ship_us: 160
ship_can: 584
survey_us: 201906
survey_can: 2019097
can_haul_offset: 200
rename_biodata_columns:
frequency: length_count
haul: haul_num
"haul ": haul_num
weight_in_haul: weight
Loading JSON and YAML into this workflow block#
For this explicit example, read the config file directly into config_params, then use config_params to populate the same variables used in the original snippet.
from pathlib import Path
import json
config_path = Path("configs/biodata_setup.json")
config_params = json.loads(config_path.read_text(encoding="utf-8"))
from pathlib import Path
import yaml
config_path = Path("configs/biodata_setup.yaml")
config_params = yaml.safe_load(config_path.read_text(encoding="utf-8"))
When this pattern is used in production, validate that required keys exist and raise a clear error before any data ingestion call is executed. This can be achieved using approaches ranging from if-else blocks to existing libraries like Pydantic.
Applying user configurations#
The previous biodata setup and ingestion code can be rewritten by incorporating the loaded configuration file. It changes only the parameter sourcing and keeps the ingestion logic intact.
# Import packages
from pathlib import Path
from echopop import ingest
from echopop.survey import biology
# Root data directory
DATA_ROOT = Path("C:/Data/EchopopData/")
# Biodata file [Excel]
BIODATA_FILE = DATA_ROOT / "survey_biodata.xlsx"
# Biodata file sheetnames
BIODATA_SHEETS = {
"catch": "biodata_catch",
"length": "biodata_length",
"specimen": "biodata_specimen",
}
# Different biodata processing parameters required for parsing the Excel-formatted dataset
SPECIES_ID = 22500 # numeric species code for Pacific hake
SHIP_US = 160 # US ship ID
SHIP_CAN = 584 # CAN ship ID
SURVEY_US = 201906 # US survey identifier
SURVEY_CAN = 2019097 # CAN survey identifier
# ---- Dictionary structure of this applied
BIODATA_SHIP_SPECIES = {
"ships": {
SHIP_US: {
"country": "US",
"survey": SURVEY_US
},
SHIP_CAN: {
"country": "CAN",
"survey": SURVEY_CAN,
"haul_offset": CAN_HAUL_OFFSET
}
},
"species_code": [SPECIES_ID]
}
# Haul UID reference dictionary
HAUL_UID_CONFIG = {
"ship_id": {"US": SHIP_US, "CAN": SHIP_CAN},
"survey_id": {"US": SURVEY_US, "CAN": SURVEY_CAN},
"species_id": SPECIES_ID,
"haul_offset": CAN_HAUL_OFFSET
}
# Biodata ingestion parameters
RENAME_BIODATA_COLUMNS = {
"frequency": "length_count",
"haul": "haul_num",
"haul ": "haul_num",
"weight_in_haul": "weight",
}
# Biodata sex label mapping
BIODATA_SEX = {
"sex": {
1: "male",
2: "female",
3: "unsexed"
}
}
# Ingest biodata
dict_df_bio = ingest.load_biological_data(
biodata_filepath=BIODATA_FILE,
biodata_sheet_map=BIODATA_SHEETS,
column_name_map=RENAME_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
# Remove specimen-only hauls
feat_biology.drop_specimen_only_hauls(dict_df_bio)
# Import packages
from pathlib import Path
from echopop import ingest
import json
from echopop.survey import biology
# Read in configuration
config_path = Path("configs/biodata_setup.json")
config_params = json.loads(config_path.read_text(encoding="utf-8"))
# Root data directory
DATA_ROOT = Path(config_params["data_root"])
# Biodata file [Excel]
BIODATA_FILE = DATA_ROOT / config_params["biodata_file"]
# Biodata file sheetnames
BIODATA_SHEETS = config_params["biodata_sheets"]
# Different biodata processing parameters required for parsing the Excel-formatted dataset
BIODATA_SHIP_SPECIES = {
"ships": {
config_params["biodata_subset"]["ship_us"]: {
"country": "US",
"survey": config_params["biodata_subset"]["survey_us"]
},
config_params["biodata_subset"]["ship_can"]: {
"country": "CAN",
"survey": config_params["biodata_subset"]["survey_can"],
"haul_offset": config_params["biodata_subset"]["can_haul_offset"]
}
},
"species_code": [config_params["biodata_subset"]["species_id"]]
}
# Haul UID reference dictionary
HAUL_UID_CONFIG = {
"ship_id": {
"US": config_params["biodata_subset"]["ship_us"],
"CAN": config_params["biodata_subset"]["ship_can"]
},
"survey_id": {
"US": config_params["biodata_subset"]["survey_us"],
"CAN": config_params["biodata_subset"]["survey_can"]
},
"species_id": config_params["biodata_subset"]["species_id"],
"haul_offset": config_params["biodata_subset"]["can_haul_offset"]
}
# Biodata ingestion parameters
RENAME_BIODATA_COLUMNS = config_params["rename_biodata_columns"]
# Biodata sex label mapping
BIODATA_SEX = {
"sex": {
1: "male",
2: "female",
3: "unsexed"
}
}
# Ingest biodata
dict_df_bio = ingest.load_biological_data(
biodata_filepath=BIODATA_FILE,
biodata_sheet_map=BIODATA_SHEETS,
column_name_map=FEAT_TO_ECHOPOP_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
# Remove specimen-only hauls
feat_biology.drop_specimen_only_hauls(dict_df_bio)
# Import packages
from pathlib import Path
from echopop import ingest
import yaml
from echopop.survey import biology
# Read in configuration
config_path = Path("configs/biodata_setup.yaml")
config_params = yaml.safe_load(config_path.read_text(encoding="utf-8"))
# Root data directory
DATA_ROOT = Path(config_params["data_root"])
# Biodata file [Excel]
BIODATA_FILE = DATA_ROOT / config_params["biodata_file"]
# Biodata file sheetnames
BIODATA_SHEETS = config_params["biodata_sheets"]
# Different biodata processing parameters required for parsing the Excel-formatted dataset
BIODATA_SHIP_SPECIES = {
"ships": {
config_params["biodata_subset"]["ship_us"]: {
"country": "US",
"survey": config_params["biodata_subset"]["survey_us"]
},
config_params["biodata_subset"]["ship_can"]: {
"country": "CAN",
"survey": config_params["biodata_subset"]["survey_can"],
"haul_offset": config_params["biodata_subset"]["can_haul_offset"]
}
},
"species_code": [config_params["biodata_subset"]["species_id"]]
}
# Haul UID reference dictionary
HAUL_UID_CONFIG = {
"ship_id": {
"US": config_params["biodata_subset"]["ship_us"],
"CAN": config_params["biodata_subset"]["ship_can"]
},
"survey_id": {
"US": config_params["biodata_subset"]["survey_us"],
"CAN": config_params["biodata_subset"]["survey_can"]
},
"species_id": config_params["biodata_subset"]["species_id"],
"haul_offset": config_params["biodata_subset"]["can_haul_offset"]
}
# Biodata ingestion parameters
RENAME_BIODATA_COLUMNS = config_params["rename_biodata_columns"]
# Biodata sex label mapping
BIODATA_SEX = {
"sex": {
1: "male",
2: "female",
3: "unsexed"
}
}
# Ingest biodata
dict_df_bio = ingest.load_biological_data(
biodata_filepath=BIODATA_FILE,
biodata_sheet_map=BIODATA_SHEETS,
column_name_map=RENAME_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
# Remove specimen-only hauls
feat_biology.drop_specimen_only_hauls(dict_df_bio)
This approach works well because users edit configuration values while the workflow logic remains stable. The most important safeguards are validating required keys before assignment and keeping key names consistent across projects so old configuration files do not silently break new workflow versions.
Conditional logic in configuration#
Configurations can be used to control the execution flow of a Python package by defining logic flags. By including specific key-value pairs—such as boolean toggles or categorical strings—the configuration file acts as a set of instructions that triggers specific if-else blocks within the code. This allows for the dynamic enabling or disabling of workflow steps without modifying the underlying script. For instance, a parameter called drop_specimen_hauls can be added to the configuration which controls whether the following line is executed:
feat_biology.drop_specimen_only_hauls(dict_df_bio)
{
"data_root": "C:/Data/EchopopData",
"biodata_file": "survey_biodata.xlsx",
"biodata_sheets": {
"catch": "biodata_catch",
"length": "biodata_length",
"specimen": "biodata_specimen"
},
"biodata_subset": {
"species_id": 22500,
"ship_us": 160,
"ship_can": 584,
"survey_us": 201906,
"survey_can": 2019097,
"can_haul_offset": 200,
},
"rename_biodata_columns": {
"frequency": "length_count",
"haul": "haul_num",
"haul ": "haul_num",
"weight_in_haul": "weight"
},
"drop_specimen_hauls": true
}
data_root: C:/Data/EchopopData
biodata_file: survey_biodata.xlsx
biodata_sheets:
catch: biodata_catch
length: biodata_length
specimen: biodata_specimen
biodata_subset:
species_id: 22500
ship_us: 160
ship_can: 584
survey_us: 201906
survey_can: 2019097
can_haul_offset: 200
rename_biodata_columns:
frequency: length_count
haul: haul_num
"haul ": haul_num
weight_in_haul: weight
drop_specimen_hauls: True
The loaded configuration can then be applied by inserting an if-else block into the code snippet.
# Remove specimen-only hauls
feat_biology.drop_specimen_only_hauls(dict_df_bio)
# Remove specimen-only hauls only when configured
if config_params["drop_specimen_hauls"]:
feat_biology.drop_specimen_only_hauls(dict_df_bio)
This if-else block makes the decision whether to drop the specimen-only hauls to be entirely configurable by the user without needing to modify the script itself every time.
Dynamic path selection#
Beyond simple parameter adjustments and if-else blocks, configuration flags can be utilized for dynamic path selection. This enables the user to instruct the software to execute a completely different branch of analysis. For instance, the workflow may be designed to trigger a specific biodata ingestion method such as biodata_format = "excel" for load_biological_data and biodata_format = "views" for load_biodata_views.
{
"data_root": "C:/Data/EchopopData",
"biodata_file": "survey_biodata.xlsx",
"biodata_sheets": {
"catch": "biodata_catch",
"length": "biodata_length",
"specimen": "biodata_specimen"
},
"biodata_subset": {
"species_id": 22500,
"ship_us": 160,
"ship_can": 584,
"survey_us": 201906,
"survey_can": 2019097,
"can_haul_offset": 200,
},
"rename_biodata_columns": {
"frequency": "length_count",
"haul": "haul_num",
"haul ": "haul_num",
"weight_in_haul": "weight"
},
"biodata_format": "excel",
"drop_specimen_hauls": true
}
data_root: C:/Data/EchopopData
biodata_file: survey_biodata.xlsx
biodata_sheets:
catch: biodata_catch
length: biodata_length
specimen: biodata_specimen
biodata_subset:
species_id: 22500
ship_us: 160
ship_can: 584
survey_us: 201906
survey_can: 2019097
can_haul_offset: 200
rename_biodata_columns:
frequency: length_count
haul: haul_num
"haul ": haul_num
weight_in_haul: weight
biodata_format: excel
drop_specimen_hauls: True
This mechanism allows for a single workflow to support multiple, distinct operational modes. Users can toggle between these predefined branches to suit different data types or analysis objects without needing to modify the internal script structure. This allows the code snippet to be modified to:
# Ingest biodata
dict_df_bio = ingest.load_biological_data(
biodata_filepath=BIODATA_FILE,
biodata_sheet_map=BIODATA_SHEETS,
column_name_map=FEAT_TO_ECHOPOP_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
# Remove specimen-only hauls
feat_biology.drop_specimen_only_hauls(dict_df_bio)
# Ingest biodata based on configured typing
if config_params["biodata_format"] == "excel":
dict_df_bio = ingest.load_biological_data(
biodata_filepath=BIODATA_FILE,
biodata_sheet_map=BIODATA_SHEETS,
column_name_map=RENAME_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
# Remove specimen-only hauls only when configured
# ---- ! This is ONLY compatible for when the Excel file format is ingested
if config_params["drop_specimen_hauls"]:
biology.drop_specimen_only_hauls(dict_df_bio)
elif config_params["biodata_format"] == "views":
dict_df_bio = ingest.load_biodata_views(
biodata_filepaths=BIODATA_FILE,
column_name_map=RENAME_BIODATA_COLUMNS,
survey_subset=BIODATA_SHIP_SPECIES,
biodata_label_map=BIODATA_SEX,
haul_uid_config=HAUL_UID_CONFIG,
)
else:
raise KeyError(
f"Configuration parameter 'biodata_format' must be either 'excel' or 'views'. "
f"Got: '{config_params['biodata_format']}'!"
)
Modular configuration management#
For more complicated workflows, modular configuration management separates settings by stage instead of storing everything in one large, cumbersome file. For instance, one configuration file could be assigned to data ingestion and another for stratification or analysis settings. This separation compartmentalizes stage-specific settings, reducing coupling between steps and lowering the risk that downstream edits unintentionally alter validated upstream parameters.
Common failure modes in non-modular setups include:
Shared keys across stages (namespace collision)
The same key name is intentionally reused in multiple workflow stages. Editing it for one stage also edits another stage.
Example:survey_yeardrives both ingestion file selection and stratification binningGlobal configuration mutation (runtime side effect)
Code changes configuration values in-memory during execution, so later or repeated steps read modified values.
Example: a function setsconfig["species_id"] = override_rideand that persists.Copy-paste drift (versioning/editing error)
Separate run files diverge inconsistently due to manual edits, creating mismatched parameter sets.
Example:ship_canis updated butsurvey_canis unchanged and left stale.Implicit cross-stage dependency (hidden coupling)
A parameter appears stage-local but is also used elsewhere via undocumented logic.
Example:stratum_columnis reused to build an ingestion join key.
Benefits of a modular approach
Isolation of parameters: Stage-specific settings stay in their own file, which reduces clutter and minimizes cross-stage edits.
Component reusability: One ingestion configuration can be paired with many stratification configurations for controlled comparisons.
Workflow scalability: New stages can be added by introducing a new configuration module without restructuring existing files.
Example layout#
One part of an example workflow may be broken up into these three stages:
biodata ingestion → stratification ingestion and apply stratification → bin age and length into their respective distributions
A configuration for each stage can then be created and maintained, such as within a directory like:
workflow_configs_hake2026/
biodata_ingestion.yaml
stratification.yaml
biodata_analysis.yaml
Configuration file format
While this example uses YAML files, modular configuration files can also be JSON or a mix of multiple file formats.
In this pattern, biodata_ingestion.yaml defines source files, sheet mappings, and other parameters as shown in previous configuration examples. stratification.yaml defines source files and other parameters for all stratification settings. And biodata_analysis.yaml stores information concerning the discretized age and length distributions.
data_root: C:/Data/EchopopData
biodata_file: survey_biodata.xlsx
biodata_sheets:
catch: biodata_catch
length: biodata_length
specimen: biodata_specimen
biodata_subset:
species_id: 22500
ship_us: 160
ship_can: 584
survey_us: 201906
survey_can: 2019097
can_haul_offset: 200
rename_biodata_columns:
frequency: length_count
haul: haul_num
"haul ": haul_num
weight_in_haul: weight
biodata_format: excel
drop_specimen_hauls: True
data_root: C:/Data/EchopopData
strata_file: survey_strata.xlsx
strata_sheets:
inpfc: INPFC
ks: "Base KS"
rename_strata_columns:
fraction_hake: nasc_proportion
haul: haul_num
stratum: stratum_num
default_stratum: 0
stratum_source: ks
stratum_name: stratum_ks
join_method: uid
age:
start: 1
end: 22
count: 22
column: age
length:
start: 2
end: 80
count: 40
column: length
The workflow then loads all three and applies them to the relevant function calls.