Skip to content
Draft
1 change: 1 addition & 0 deletions .ci_support/environment-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ dependencies:
- tqdm =4.67.3
- jupyter-book =1.0.0
- python =3.12
- pydantic =2.12.5
- hatchling =1.29.0
- hatch-vcs =0.5.0
1 change: 1 addition & 0 deletions .ci_support/environment-old.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ dependencies:
- jinja2 =2.11.3
- paramiko =2.7.1
- tqdm =4.66.1
- pydantic =2.5.3
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

CI environment files pin pydantic to different versions (2.5.3 here vs 2.12.5 in the main/doc environments and in pyproject.toml). This can lead to inconsistent validation behavior and CI results across environments. Align the pinned versions (or use a compatible range) across all environment files.

Suggested change
- pydantic =2.5.3
- pydantic =2.12.5

Copilot uses AI. Check for mistakes.
- hatchling =1.27.0
- hatch-vcs =0.4.0
1 change: 1 addition & 0 deletions .ci_support/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ dependencies:
- tqdm =4.67.3
- hatchling =1.29.0
- hatch-vcs =0.5.0
- pydantic =2.12.5
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ remote = [
"tqdm==4.67.3",
]
twofactor = ["pyauthenticator==0.3.0"]
pydantic = ["pydantic==2.12.5"]

[project.scripts]
pysqa = "pysqa.cmd:command_line"
Expand Down
13 changes: 10 additions & 3 deletions src/pysqa/base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
from pysqa.base.core import QueueAdapterCore, execute_command
from pysqa.base.validate import check_queue_parameters, value_error_if_none

try:
from pysqa.base.models import validate_config
except ImportError:

def validate_config(config: dict) -> dict:
return config
Comment on lines +14 to +17
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

Catching a broad ImportError here will also hide unrelated import problems inside pysqa.base.models (e.g., a coding error) and will silently disable validation when pydantic is missing. If validation is expected to be available, import unconditionally; otherwise catch ModuleNotFoundError specifically for pydantic and raise a clear message telling users to install the extra.

Suggested change
except ImportError:
def validate_config(config: dict) -> dict:
return config
except ModuleNotFoundError as e:
# Handle the optional dependency on pydantic explicitly; re-raise other import errors.
if e.name == "pydantic":
def validate_config(config: dict) -> dict:
raise ModuleNotFoundError(
"Configuration validation requires 'pydantic'. "
"Install the appropriate extra (e.g. 'pysqa[validation]') to enable validation."
) from e
else:
raise

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify dependency declaration and locate all validation fallback/import patterns.
fd 'pyproject.toml' --exec sed -n '1,260p' {}
rg -nP "pydantic|from pysqa\\.base\\.models import validate_config|except ImportError|except ModuleNotFoundError" -C2

Repository: pyiron/pysqa

Length of output: 5952


🏁 Script executed:

# Find all usages of validate_config to understand where it's called
rg -n "validate_config" -B2 -A2

# Check if pydantic is conditionally required or truly optional
rg -n "pydantic" --type python

# Look at the actual validation function in models.py
cat -n src/pysqa/base/models.py

Repository: pyiron/pysqa

Length of output: 2792


🏁 Script executed:

# Search for any documentation or comments about optional pydantic
rg -n "optional|pydantic" -i --type-list | head -5
cat README.md | head -100

# Look for any conditional imports or environment checks related to pydantic
rg -n "pydantic" -B3 -A3

# Check if config is accessed with square bracket notation after validate_config
sed -n '80,90p' src/pysqa/base/config.py

Repository: pyiron/pysqa

Length of output: 10580


🏁 Script executed:

# Look at test_config.py to see if it handles missing pydantic
cat -n tests/unit/base/test_config.py | head -50

# Check if there are any skip decorators or conditional imports in test_config
rg -n "skip|ImportError|pydantic" tests/unit/base/test_config.py -B2 -A2

Repository: pyiron/pysqa

Length of output: 2038


Replace broad ImportError catch with specific error handling for missing pydantic.

Catching ImportError without specificity silently disables config validation when pydantic is unavailable, deferring failures to KeyError at line 87 instead of providing a clear validation/installation error. The tests (test_pydantic_validation_missing_queues, test_pydantic_validation_wrong_type) demonstrate that validation is core functionality and pydantic is not truly optional despite being listed in optional-dependencies.

Proposed fix
 try:
     from pysqa.base.models import validate_config
-except ImportError:
-
-    def validate_config(config: dict) -> dict:
-        return config
+except ModuleNotFoundError as exc:
+    if exc.name == "pydantic":
+        raise RuntimeError(
+            "pydantic is required for configuration validation."
+        ) from exc
+    raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pysqa/base/config.py` around lines 12 - 17, The current broad ImportError
catch around "from pysqa.base.models import validate_config" suppresses all
import errors and hides missing-pydantic issues; instead, if the import raises
because pydantic is not present, raise a clear error advising to install
pydantic; for other import failures re-raise the original exception. Update the
except block that defines the fallback validate_config to inspect the caught
ImportError (or ModuleNotFoundError) and if the missing module is "pydantic"
raise a descriptive RuntimeError/ImportError explaining pydantic is required for
validate_config (mentioning validate_config and pysqa.base.models), otherwise
re-raise the original exception so unrelated import problems are not silently
ignored.



class Queues:
"""
Expand Down Expand Up @@ -73,10 +80,10 @@ def __init__(
directory: str = "~/.queues",
execute_command: Callable = execute_command,
):
self._config = validate_config(config)
super().__init__(
queue_type=config["queue_type"], execute_command=execute_command
queue_type=self._config["queue_type"], execute_command=execute_command
)
self._config = config
self._fill_queue_dict(queue_lst_dict=self._config["queues"])
self._load_templates(queue_lst_dict=self._config["queues"], directory=directory)
self._queues = Queues(self.queue_list)
Expand Down Expand Up @@ -309,7 +316,7 @@ def _load_templates(queue_lst_dict: dict, directory: str = ".") -> None:
directory (str, optional): The directory where the queue template files are located. Defaults to ".".
"""
for queue_dict in queue_lst_dict.values():
if "script" in queue_dict:
if "script" in queue_dict and queue_dict["script"] is not None:
with open(os.path.join(directory, queue_dict["script"])) as f:
try:
queue_dict["template"] = Template(f.read())
Expand Down
59 changes: 59 additions & 0 deletions src/pysqa/base/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from typing import Optional, Union

from pydantic import BaseModel, ConfigDict


class QueueModel(BaseModel):
"""
Pydantic model for a single queue configuration.
"""

model_config = ConfigDict(extra="allow")

script: Optional[str] = None
cores_min: Optional[int] = None
cores_max: Optional[int] = None
run_time_max: Optional[int] = None
memory_max: Optional[Union[int, str]] = None
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

memory_max previously supported float values (see check_queue_parameters(... memory_max: Optional[Union[int, float, str]])). Restricting this field to int | str can break existing YAML configs that specify non-integer numeric memory limits. Consider allowing float as well (or documenting/validating conversion rules explicitly).

Suggested change
memory_max: Optional[Union[int, str]] = None
memory_max: Optional[Union[int, float, str]] = None

Copilot uses AI. Check for mistakes.


class ConfigModel(BaseModel):
"""
Pydantic model for the overall configuration.
"""

model_config = ConfigDict(extra="allow")

queue_type: str
queue_primary: Optional[str] = None
ssh_host: Optional[str] = None
ssh_username: Optional[str] = None
known_hosts: Optional[str] = None
ssh_key: Optional[str] = None
ssh_password: Optional[str] = None
ssh_ask_for_password: Optional[str] = None
ssh_key_passphrase: Optional[str] = None
ssh_two_factor_authentication: bool = False
ssh_authenticator_service: Optional[str] = None
ssh_proxy_host: Optional[str] = None
ssh_remote_config_dir: Optional[str] = None
ssh_remote_path: Optional[str] = None
ssh_local_path: Optional[str] = None
ssh_port: Optional[int] = None
ssh_continous_connection: bool = True
ssh_delete_file_on_remote: bool = False
Comment on lines +43 to +44
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The defaults for ssh_continous_connection and ssh_delete_file_on_remote don’t match the existing runtime defaults in RemoteQueueAdapter (currently defaults to False and True respectively via config.get(...)). Because validate_config() uses model_dump() without excluding defaults, these values can be injected into qa.config even when the user didn’t specify them, creating confusing/incorrect apparent configuration. Align the defaults with current behavior and/or exclude defaults in model_dump().

Suggested change
ssh_continous_connection: bool = True
ssh_delete_file_on_remote: bool = False
ssh_continous_connection: bool = False
ssh_delete_file_on_remote: bool = True

Copilot uses AI. Check for mistakes.
python_executable: Optional[str] = None
queues: dict[str, QueueModel]


def validate_config(config: dict) -> dict:
"""
Validate the configuration dictionary against the ConfigModel.

Args:
config (dict): The configuration dictionary to validate.

Returns:
dict: The validated configuration dictionary.
"""
return ConfigModel(**config).model_dump()
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

validate_config() returns model_dump() with default settings, which includes fields set to None/defaults (e.g., queue entries get script: None). This changes the shape of config compared to what was loaded from YAML and is what necessitated extra is not None checks elsewhere. Consider using model_dump(exclude_none=True, exclude_defaults=True) (or exclude_unset=True) to preserve the original config structure while still validating/coercing types.

Suggested change
return ConfigModel(**config).model_dump()
return ConfigModel(**config).model_dump(exclude_none=True, exclude_defaults=True)

Copilot uses AI. Check for mistakes.
55 changes: 54 additions & 1 deletion tests/unit/base/test_config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,61 @@
import unittest
from pysqa.base.config import QueueAdapterWithConfig
import sys
from unittest.mock import patch


class TestConfig(unittest.TestCase):
def test_bad_queue_type(self):
from pysqa.base.config import QueueAdapterWithConfig

with self.assertRaises(ValueError):
QueueAdapterWithConfig(config={"queue_type": "error", "queues": {}})

def test_pydantic_validation_missing_queues(self):
from pysqa.base.config import QueueAdapterWithConfig

with self.assertRaises(ValueError):
QueueAdapterWithConfig(config={"queue_type": "SLURM"})

def test_pydantic_validation_wrong_type(self):
from pysqa.base.config import QueueAdapterWithConfig

with self.assertRaises(ValueError):
QueueAdapterWithConfig(
config={
"queue_type": "SLURM",
"queues": {"sq": {"script": "slurm.sh", "cores_min": "not_an_int"}},
}
)

def test_pydantic_validation_extra_fields(self):
from pysqa.base.config import QueueAdapterWithConfig

config = {
"queue_type": "SLURM",
"queue_primary": "sq",
"queues": {"sq": {"extra_field": "allowed"}},
"extra_top_level": "also_allowed",
}
qa = QueueAdapterWithConfig(config=config)
self.assertEqual(qa.config["queues"]["sq"]["extra_field"], "allowed")
self.assertEqual(qa.config["extra_top_level"], "also_allowed")

def test_no_pydantic_validation_extra_fields(self):
with patch.dict('sys.modules', {'pydantic': None}):
if 'pysqa.base.models' in sys.modules:
del sys.modules['pysqa.base.models']

if 'pysqa.base.config' in sys.modules:
del sys.modules['pysqa.base.config']

from pysqa.base.config import QueueAdapterWithConfig

config = {
"queue_type": "SLURM",
"queue_primary": "sq",
"queues": {"sq": {"extra_field": "allowed"}},
"extra_top_level": "also_allowed",
}
qa = QueueAdapterWithConfig(config=config)
self.assertEqual(qa.config["queues"]["sq"]["extra_field"], "allowed")
self.assertEqual(qa.config["extra_top_level"], "also_allowed")
Loading