cli: add tests for agebd service new

This commit is contained in:
Benjamin Labrecque
2026-07-23 15:41:22 +02:00
parent 9174f573d4
commit e310453487
28 changed files with 405 additions and 166 deletions
+7 -1
View File
@@ -15,6 +15,7 @@ dependencies = [
"gitpython>=3.1.50",
"pydantic>=2.13.4",
"pydantic-settings>=2.14.2",
"pytest-mock>=3.15.1",
"pyyaml>=6.0.3",
"typer>=0.23.2",
]
@@ -22,6 +23,7 @@ dependencies = [
# Dev dependencies
[dependency-groups]
dev = [
"ipython>=8.39.0",
"pyright>=1.1.410",
"pytest>=8.4.2",
"ruff>=0.15.19",
@@ -46,6 +48,10 @@ where = ["src"]
[tool.uv]
package = true
[tool.pytest.ini_options]
# avoid pytest running tests inside tests/fixtures
testpaths = ["tests/tests"]
[tool.ruff]
include = [
"src/**/*.py",
@@ -58,7 +64,7 @@ extend-select = [
# use isort to sort imports
"I",
# remove unused imports
"F401",
# "F401",
]
[tool.ruff.lint.isort]
+41 -9
View File
@@ -1,28 +1,31 @@
from pathlib import Path
from pydantic import BaseModel, DirectoryPath, FilePath
from pydantic import DirectoryPath, FilePath, model_validator
from pydantic_settings import BaseSettings
from core.utils import get_git_root
REPO_ROOT = get_git_root(__file__)
class Paths(BaseModel):
class Paths(BaseSettings):
# fmt: off
repo_root: DirectoryPath = REPO_ROOT
repo_root: DirectoryPath = REPO_ROOT
service_registry_file: FilePath = repo_root / "config" / "services_registry.yml"
iocs_overview_filename: FilePath = repo_root / "docs" / "user" / "ioc" / "ioc_overview.md"
master_hla_names_file: FilePath = repo_root / "services" / "master" / "current" / "app" / "config" / "hla_names.yml"
master_ioc_subs_file: FilePath = repo_root / "services" / "master" / "current" / "ioc" / "AGEBD-CPCL-MASTER_main.subs"
service_manager_ui_file: FilePath = repo_root / "qt" / "A_BD_ServiceManager.ui"
services_dir: DirectoryPath = Path("services")
service_registry_file: FilePath = Path("config/services_registry.yml")
iocs_overview_filename: FilePath = Path("docs/user/ioc/iocs_overview.md")
master_hla_names_file: FilePath = Path("services/master/current/app/config/hla_names.yml")
master_ioc_subs_file: FilePath = Path("services/master/current/ioc/AGEBD-CPCL-MASTER_main.subs")
service_manager_ui_file: FilePath = Path("qt/A_BD_ServiceManager.ui")
# fmt: on
def get_files_to_stage(self, service_dir_name: str) -> list[str]:
"""
Returns all required relative file paths for git staging.
"""
service_current = self.repo_root / "services" / service_dir_name / "current"
service_current = self.services_dir / service_dir_name / "current"
staged_paths = [
service_current,
@@ -40,3 +43,32 @@ class Paths(BaseModel):
Convert any absolute path into a string path relative to repo_root.
"""
return str(path.relative_to(self.repo_root))
@model_validator(mode="before")
@classmethod
def resolve_paths_against_repo_root(cls, data):
"""
Allows to set a different repo_root on object creation:
paths = Paths(repo_root=Path("/my/path"))
and have the other paths use that repo_root.
"""
if not isinstance(data, dict):
return data
# Determine what repo_root to use (either passed in data or default constant)
root = Path(data.get("repo_root", REPO_ROOT))
# Prep-end root to any relative paths before Pydantic validates FilePath / DirectoryPath
for field_name, field_info in cls.model_fields.items():
if field_name == "repo_root":
continue
val = data.get(field_name, field_info.default)
if val is not None:
path_val = Path(val)
if not path_val.is_absolute():
data[field_name] = root / path_val
return data
+68 -63
View File
@@ -1,83 +1,88 @@
import logging
from pathlib import Path
import git
logger = logging.getLogger()
def git_push_changes(
repo: git.Repo,
branch_name: str,
files_to_stage: list[str],
commit_msg: str,
):
"""
Push newly created service to Gitea
"""
logger.info(f"Staging files inside: {files_to_stage}")
repo.index.add(files_to_stage)
class GitRepoManager:
def __init__(self, repo_root: Path | str) -> None:
self.repo = git.Repo(repo_root)
# Check if there are changes to avoid empty commit crashes
if not repo.is_dirty(index=True, working_tree=False):
logger.info("No changes detected. Skipping commit.")
return
def git_push_changes(
self,
branch_name: str,
files_to_stage: list[str],
commit_msg: str,
):
"""
Push newly created service to Gitea
"""
logger.info(f"Staging files inside: {files_to_stage}")
self.repo.index.add(files_to_stage)
logger.info("Committing changes...")
repo.index.commit(commit_msg)
# Check if there are changes to avoid empty commit crashes
if not self.repo.is_dirty(index=True, working_tree=False):
logger.info("No changes detected. Skipping commit.")
return
try:
logger.info(f"Pushing branch '{branch_name}' to origin...")
origin = repo.remote(name="origin")
origin.push(refspec=f"{branch_name}:{branch_name}").raise_if_error()
except git.GitCommandError as e:
logger.error(f"Push failed due to an error: {e}")
logger.info("Rolling back local Git index changes...")
logger.info("Committing changes...")
self.repo.index.commit(commit_msg)
# Step A: Undo the local commit if it was created (moves HEAD back 1 commit, keeps changes)
# This prevents leaving a dead/unpushed commit on your local branch
try:
repo.head.reset("HEAD~1", index=True, working_tree=False)
except Exception:
# Fallback if the exception happened BEFORE the commit step even ran
pass
logger.info(f"Pushing branch '{branch_name}' to origin...")
origin = self.repo.remote(name="origin")
origin.push(refspec=f"{branch_name}:{branch_name}").raise_if_error()
except git.GitCommandError as e:
logger.error(f"Push failed due to an error: {e}")
logger.info("Rolling back local Git index changes...")
# Step B: Unstage the specific files (Equivalent to: git reset HEAD <files>)
# This leaves the files intact but removes them from the staging area
repo.index.reset(paths=files_to_stage)
logger.info("Staging area successfully reset to clean state.")
# Step A: Undo the local commit if it was created (moves HEAD back 1 commit, keeps changes)
# This prevents leaving a dead/unpushed commit on your local branch
try:
self.repo.head.reset("HEAD~1", index=True, working_tree=False)
except Exception:
# Fallback if the exception happened BEFORE the commit step even ran
pass
# TODO: we could also force push... need to think about workflow though.
raise RuntimeError(
f"Failed to push to git due to: {e}.\nDoes the branch already exist in Gitea? If yes, it needs to be deleted first."
)
# Step B: Unstage the specific files (Equivalent to: git reset HEAD <files>)
# This leaves the files intact but removes them from the staging area
self.repo.index.reset(paths=files_to_stage)
logger.info("Staging area successfully reset to clean state.")
logger.info("Successfully pushed!")
# TODO: we could also force push... need to think about workflow though.
raise RuntimeError(
f"Failed to push to git due to: {e}.\nDoes the branch already exist in Gitea? If yes, it needs to be deleted first."
)
logger.info("Successfully pushed!")
def delete_local_branch(repo: git.Repo, branch_name: str):
"""
Delete local branch if it already exists
"""
if branch_name in repo.branches:
logger.info(f"Deleting local branch '{branch_name}'...")
repo.git.branch("-D", branch_name)
def get_active_branch_name(self):
return self.repo.active_branch.name
def delete_local_branch(self, branch_name: str):
"""
Delete local branch if it already exists
"""
if branch_name in self.repo.branches:
logger.info(f"Deleting local branch '{branch_name}'...")
self.repo.git.branch("-D", branch_name)
def switch_branch(repo: git.Repo, branch_name: str):
"""
Create branch if it doesn't exist, then checkout
"""
logger.info(f"Switching to branch '{branch_name}'...")
if branch_name in repo.heads:
new_branch = repo.heads[branch_name]
else:
new_branch = repo.create_head(branch_name)
new_branch.checkout()
def switch_branch(self, branch_name: str):
"""
Create branch if it doesn't exist, then checkout
"""
logger.info(f"Switching to branch '{branch_name}'...")
if branch_name in self.repo.heads:
new_branch = self.repo.heads[branch_name]
else:
new_branch = self.repo.create_head(branch_name)
new_branch.checkout()
def assert_clean_repo(repo: git.Repo):
if repo.is_dirty(index=True, working_tree=True):
raise RuntimeError(
"Your Git repository has uncommitted modifications to tracked files.\n"
"Please commit, stash, or discard them before proceeding."
)
def assert_clean_repo(self):
if self.repo.is_dirty(index=True, working_tree=True):
raise RuntimeError(
"Your Git repository has uncommitted modifications to tracked files.\n"
"Please commit, stash, or discard them before proceeding."
)
+18 -23
View File
@@ -4,15 +4,9 @@ import subprocess
from pathlib import Path
import copier
import git
from core.exceptions import UVError
from core.git import (
assert_clean_repo,
delete_local_branch,
git_push_changes,
switch_branch,
)
from core.git import GitRepoManager
from core.utils import get_git_root
from models import (
Service,
@@ -23,12 +17,12 @@ logger = logging.getLogger(__name__)
REPO_ROOT = get_git_root(__file__)
SERVICE_TEMPLATES_DIR = REPO_ROOT / "templates" / "service"
SERVICE_DEST_DIR = REPO_ROOT / "services"
class ServiceCreator:
def __init__(self, ctx: ServicesContext) -> None:
def __init__(self, ctx: ServicesContext, repo_manager: GitRepoManager) -> None:
self.ctx = ctx
self.repo_manager = repo_manager
def add_service(
self,
@@ -38,9 +32,8 @@ class ServiceCreator:
ui_name: str,
ui_filename: str,
):
repo = git.Repo(REPO_ROOT)
assert_clean_repo(repo)
original_branch = repo.active_branch.name
self.repo_manager.assert_clean_repo()
original_branch = self.repo_manager.get_active_branch_name()
backup = self.ctx.snapshot()
@@ -53,8 +46,8 @@ class ServiceCreator:
)
branch_name = f"feature/add-service-{service.dir_name}"
delete_local_branch(repo, branch_name)
switch_branch(repo, branch_name)
self.repo_manager.delete_local_branch(branch_name)
self.repo_manager.switch_branch(branch_name)
try:
self._render_new_service_templates(
@@ -66,21 +59,21 @@ class ServiceCreator:
self.ctx.write_all()
files_to_stage = self.ctx.paths.get_files_to_stage(service_dir_name=service.dir_name)
files_to_stage = self.ctx.write_paths.get_files_to_stage(
service_dir_name=service.dir_name
)
# TODO: can't push if branch already exists in remote
git_push_changes(
repo=repo,
self.repo_manager.git_push_changes(
branch_name=branch_name,
files_to_stage=files_to_stage,
commit_msg=f"feature: add new service {service.name_lower}",
)
switch_branch(repo, original_branch)
self.repo_manager.switch_branch(original_branch)
except Exception as e:
shutil.rmtree(Path(SERVICE_DEST_DIR / service.dir_name), ignore_errors=True)
print("WRITE ALL")
shutil.rmtree(self.ctx.write_paths.services_dir / service.dir_name, ignore_errors=True)
backup.write_all()
switch_branch(repo, original_branch)
self.repo_manager.switch_branch(original_branch)
raise e
def _render_new_service_templates(self, service: Service, user: str, ui_filename: str):
@@ -89,7 +82,7 @@ class ServiceCreator:
"""
copier.run_copy(
src_path=str(SERVICE_TEMPLATES_DIR),
dst_path=str(SERVICE_DEST_DIR),
dst_path=str(self.ctx.write_paths.services_dir),
data={
"service_name_upper": service.name_upper,
"service_name_lower": service.name_lower,
@@ -103,7 +96,9 @@ class ServiceCreator:
"""
Generates a uv.lock file by running 'uv lock' in the target directory.
"""
app_path = Path(SERVICE_DEST_DIR / service.dir_name / "current" / "app").resolve()
app_path = Path(
self.ctx.write_paths.services_dir / service.dir_name / "current" / "app"
).resolve()
logger.info(f"Generating uv.lock inside: {app_path}")
try:
+16 -16
View File
@@ -1,5 +1,3 @@
from typing import Any, Dict
from pydantic import BaseModel
from config.paths import Paths
@@ -8,6 +6,7 @@ from models import IocsOverview, MasterHLANames, MasterIocSubs, ServiceManagerUI
class ServicesContext(BaseModel):
paths: Paths
_write_paths: Paths | None = None
service_registry: ServiceRegistry
master_hla_names: MasterHLANames
@@ -15,6 +14,15 @@ class ServicesContext(BaseModel):
master_ioc_subs: MasterIocSubs
service_manager_ui: ServiceManagerUI
@property
def write_paths(self) -> Paths:
"""
Mainly used to change the write location in tests
"""
if self._write_paths is None:
return self.paths
return self._write_paths
@classmethod
def load_from_disk(cls, paths: Paths) -> "ServicesContext":
return cls(
@@ -27,7 +35,7 @@ class ServicesContext(BaseModel):
)
def snapshot(self) -> "ServicesContext":
"""Creates an in-memory backup of all configs for rollback."""
"""Creates an backup of all configs for rollback."""
return ServicesContext(
paths=self.paths,
service_registry=self.service_registry.model_copy(deep=True),
@@ -37,18 +45,10 @@ class ServicesContext(BaseModel):
service_manager_ui=self.service_manager_ui.model_copy(deep=True),
)
def restore_snapshot(self, snapshot: Dict[str, Any]) -> None:
"""Restores state from a backup dictionary."""
self.service_registry = snapshot["service_registry"]
self.master_hla_names = snapshot["master_hla_names"]
self.iocs_overview = snapshot["iocs_overview"]
self.master_ioc_subs = snapshot["master_ioc_subs"]
self.service_manager_ui = snapshot["service_manager_ui"]
def write_all(self) -> None:
"""Persists all modified objects to disk."""
self.service_registry.write_to_file(self.paths.service_registry_file)
self.master_hla_names.write_to_file(self.paths.master_hla_names_file)
self.iocs_overview.write_to_file(self.paths.iocs_overview_filename)
self.master_ioc_subs.write_to_file(self.paths.master_ioc_subs_file)
self.service_manager_ui.write_to_file(self.paths.service_manager_ui_file)
self.service_registry.write_to_file(self.write_paths.service_registry_file)
self.master_hla_names.write_to_file(self.write_paths.master_hla_names_file)
self.iocs_overview.write_to_file(self.write_paths.iocs_overview_filename)
self.master_ioc_subs.write_to_file(self.write_paths.master_ioc_subs_file)
self.service_manager_ui.write_to_file(self.write_paths.service_manager_ui_file)
+5
View File
@@ -115,6 +115,11 @@ class ServiceManagerUI(BaseModel):
return cainclude_height
def write_to_file(self, path: Path) -> None:
# raw_xml = ET.tostring(self.tree.getroot(), encoding="utf-8").decode("utf-8")
#
# # Clean up newline right before </ui>
# cleaned_xml = raw_xml.replace("\n</ui>", "</ui>")
# path.write_text(cleaned_xml)
self.tree.write(path, encoding="utf-8", xml_declaration=True)
@@ -1,4 +1,4 @@
| IOC NAME | Description |
|---|---|
| AGEBD-CPCL-MASTER | IOC providing PVs ... |
| AGEBD-CPCL-TEST-SERVICE | Test description |
| AGEBD-CPCL-TEST-SERVICE | Test Description |
@@ -138,7 +138,7 @@ caLabel[statusTip="Operation"]{
</rect>
</property>
<property name="macro">
<string>IOC=AGEBD-CPCL-DBPM3CURR,service=DBPM3CURR,name=DBPM3Current,panel=1,svc_exist=1,panelcmd=A_BD_DBPM3Current.ui;IOC=AGEBD-CPCL-TAUBPM,service=TAUBPM,name=LifetimeBPM,panel=1,svc_exist=1,panelcmd=A_BD_LifetimeBPM.ui;IOC=AGEBD-CPCL-TAUPCT,service=TAUPCT,name=LifetimePCT,panel=1,svc_exist=1,panelcmd=A_BD_LifetimePCT.ui;IOC=AGEBD-CPCL-TIMING,service=TIMING,name=Timing,panel=1,svc_exist=1,panelcmd=A_BD_Timing.ui;IOC=AGEBD-CPCL-SCRUBBING,service=SCRUBBING,name=Scrubbing,panel=1,svc_exist=1,panelcmd=A_BD_Scrubbing.ui;IOC=AGEBD-CPCL-INJECTIONGUARD,service=INJECTIONGUARD,name=InjectionGuard,panel=1,svc_exist=1,panelcmd=A_BD_InjectionGuard.ui;IOC=AGEBD-CPCL-POSTMORTEMLOG,service=POSTMORTEMLOG,name=PostMortemLog,panel=1,svc_exist=1,panelcmd=A_BD_PostMortemLog.ui;IOC=AGEBD-CPCL-TUNEBUMP,service=TUNEBUMP,name=TuneBump,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-PLOTS,service=PLOTS,name=Plots,panel=0,svc_exist=1,panelcmd=A_BD_Plots.ui;IOC=AGEBD-CPCL-ORBITBUMP,service=ORBITBUMP,name=OrbitBump,panel=1,svc_exist=0,panelcmd=A_BD_OrbitBump.ui;IOC=AGEBD-CPCL-TUNEFBX,service=TUNEFBX,name=Hor. Tune Feedback,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-TUNEFBY,service=TUNEFBY,name=Ver. Tune Feedback,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-TEST-SERVICE,service=TEST-SERVICE,name=Test Service Ui Name,panel=1,svc_exist=1,panelcmd=A_BD_TestService.ui;</string>
<string>IOC=AGEBD-CPCL-DBPM3CURR,service=DBPM3CURR,name=DBPM3Current,panel=1,svc_exist=1,panelcmd=A_BD_DBPM3Current.ui;IOC=AGEBD-CPCL-TAUBPM,service=TAUBPM,name=LifetimeBPM,panel=1,svc_exist=1,panelcmd=A_BD_LifetimeBPM.ui;IOC=AGEBD-CPCL-TAUPCT,service=TAUPCT,name=LifetimePCT,panel=1,svc_exist=1,panelcmd=A_BD_LifetimePCT.ui;IOC=AGEBD-CPCL-TIMING,service=TIMING,name=Timing,panel=1,svc_exist=1,panelcmd=A_BD_Timing.ui;IOC=AGEBD-CPCL-SCRUBBING,service=SCRUBBING,name=Scrubbing,panel=1,svc_exist=1,panelcmd=A_BD_Scrubbing.ui;IOC=AGEBD-CPCL-INJECTIONGUARD,service=INJECTIONGUARD,name=InjectionGuard,panel=1,svc_exist=1,panelcmd=A_BD_InjectionGuard.ui;IOC=AGEBD-CPCL-POSTMORTEMLOG,service=POSTMORTEMLOG,name=PostMortemLog,panel=1,svc_exist=1,panelcmd=A_BD_PostMortemLog.ui;IOC=AGEBD-CPCL-TUNEBUMP,service=TUNEBUMP,name=TuneBump,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-PLOTS,service=PLOTS,name=Plots,panel=0,svc_exist=1,panelcmd=A_BD_Plots.ui;IOC=AGEBD-CPCL-ORBITBUMP,service=ORBITBUMP,name=OrbitBump,panel=1,svc_exist=0,panelcmd=A_BD_OrbitBump.ui;IOC=AGEBD-CPCL-TUNEFBX,service=TUNEFBX,name=Hor. Tune Feedback,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-TUNEFBY,service=TUNEFBY,name=Ver. Tune Feedback,panel=1,svc_exist=1,panelcmd=A_BD_TuneBump.ui;IOC=AGEBD-CPCL-TEST-SERVICE,service=TEST-SERVICE,name=Test Service Ui Name,panel=1,svc_exist=1,panelcmd=A_BD_TestService.ui</string>
</property>
<property name="filename" stdset="0">
<string notr="true">A_BD_ServiceManager_inc.ui</string>
@@ -1,5 +1,5 @@
file MASTER.template {
pattern { SUFFIX SERVICE STARTON AUTOOFF }
{ "{{ agebd_env_suffix }}", "MASTER" , "0", "30" }
{ "{{ agebd_env_suffix }}", "TEST-SERVICE" , "0", "30" }
{ "{{ agebd_env_suffix }}", "TEST-SERVICE" , "0", "0" }
}
@@ -5,8 +5,6 @@ from agebd.runner import CallbackRunner
from agebd.utils import init_logging
from agebd_test_service import PVs, Service
# TODO: remove: cicd test 10
def main(
log_level: LogLevel = typer.Option(
@@ -38,7 +38,7 @@ class PVs(BasePVs):
# TODO: class attribute names usually lowercase
# usually it is advisable to trigger the mainloop of a service on changes of certain PVs:
self.CallbackPV = PV("AGEBD-TEST-SERVICE:CALLBACK", auto_monitor=dbr.DBE_VALUE) # TODO: ok?
self.CallbackPV = PV("AGEBD-TEST-SERVICE:CALLBACK", auto_monitor=dbr.DBE_VALUE)
class Service(BaseService[PVs]):
+78 -3
View File
@@ -1,3 +1,8 @@
import filecmp
from difflib import unified_diff
from pathlib import Path
from unittest.mock import patch
from pytest_mock import MockerFixture
from config.paths import Paths
@@ -5,12 +10,18 @@ from core.git import GitRepoManager
from core.service_creator import ServiceCreator
from core.utils import get_git_root
from models.context import ServicesContext
from tests.tests.utils import are_dir_trees_equal
REPO_ROOT = get_git_root(__file__)
TEST_REPO_ROOT = REPO_ROOT / "cli" / "tests" / "fixtures" / "test_repo_root"
EXPECTED_OUTPUT_DIR = REPO_ROOT / "cli" / "tests" / "fixtures" / "expected_output"
mock_agebd_pyproject_toml = """[project]
name = "agebd"
version = "0.1.0"
description = "SLS HLA Framework"
requires-python = "==3.10.*"
"""
def test_paths_exist():
"""
@@ -26,8 +37,32 @@ def test_add_new_service(tmp_path, mocker: MockerFixture):
"""
paths = Paths(repo_root=TEST_REPO_ROOT)
with (
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.is_file", return_value=True),
patch("pathlib.Path.is_dir", return_value=True),
):
# Avoid Pydantic's FilePath / DirectoryPath validation errors for this
# test
write_paths = Paths(repo_root=tmp_path)
# Outside of tests, these file are read and written to
# In test we read from one location and write to a tmp path
# so we need to make sure the paths exist
(tmp_path / write_paths.services_dir.parent).mkdir(parents=True, exist_ok=True)
(tmp_path / write_paths.service_registry_file.parent).mkdir(parents=True, exist_ok=True)
(tmp_path / write_paths.iocs_overview_filename.parent).mkdir(parents=True, exist_ok=True)
(tmp_path / write_paths.master_hla_names_file.parent).mkdir(parents=True, exist_ok=True)
(tmp_path / write_paths.master_ioc_subs_file.parent).mkdir(parents=True, exist_ok=True)
(tmp_path / write_paths.service_manager_ui_file.parent).mkdir(parents=True, exist_ok=True)
# Because uv needs packages/agebd to generate the lock file of the services as it is
# a dependency
(tmp_path / "packages/agebd").mkdir(parents=True, exist_ok=True)
(tmp_path / "packages/agebd/pyproject.toml").write_text(mock_agebd_pyproject_toml)
ctx = ServicesContext.load_from_disk(paths)
ctx.write_paths = tmp_path
ctx._write_paths = write_paths
creator = ServiceCreator(ctx=ctx, repo_manager=mocker.Mock(spec=GitRepoManager))
creator.add_service(
@@ -38,4 +73,44 @@ def test_add_new_service(tmp_path, mocker: MockerFixture):
ui_filename="TestService",
)
assert are_dir_trees_equal(tmp_path, EXPECTED_OUTPUT_DIR)
assert_are_dir_trees_equal(tmp_path, EXPECTED_OUTPUT_DIR, tmp_path)
def assert_are_dir_trees_equal(dir1, dir2, curr_dir: Path | None = None):
"""
Compare two directories recursively. Files in each directory are
assumed to be equal if their names and contents are equal.
@param dir1: First directory path
@param dir2: Second directory path
"""
if curr_dir is None:
curr_dir = Path()
dirs_cmp = filecmp.dircmp(dir1, dir2)
assert len(dirs_cmp.left_only) == 0, f"diff in {curr_dir} dir"
assert len(dirs_cmp.right_only) == 0, f"diff in {curr_dir} dir"
assert len(dirs_cmp.funny_files) == 0, f"diff in {curr_dir} dir"
(_, mismatch, _) = filecmp.cmpfiles(dir1, dir2, dirs_cmp.common_files, shallow=False)
for filename in mismatch:
if filename == "uv.lock":
continue
assert_files_equal(dir1 / filename, dir2 / filename)
for common_dir in dirs_cmp.common_dirs:
new_dir1 = dir1 / common_dir
new_dir2 = dir2 / common_dir
assert_are_dir_trees_equal(new_dir1, new_dir2, curr_dir / common_dir)
return True
def assert_files_equal(f1, f2):
actual_lines = [
line + "\n" for line in Path(f1).read_text(encoding="utf-8").strip().splitlines()
]
expected_lines = [
line + "\n" for line in Path(f2).read_text(encoding="utf-8").strip().splitlines()
]
diff = list(unified_diff(actual_lines, expected_lines, n=0))
assert diff == [], f"Unexpected file contents in file {f1}:\n" + "".join(diff)
Generated
+164 -34
View File
@@ -39,12 +39,14 @@ dependencies = [
{ name = "gitpython" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pytest-mock" },
{ name = "pyyaml" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "ipython" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
@@ -59,12 +61,14 @@ requires-dist = [
{ name = "gitpython", specifier = ">=3.1.50" },
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "pytest-mock", specifier = ">=3.15.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "typer", specifier = ">=0.23.2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "ipython", specifier = ">=8.39.0" },
{ name = "pyright", specifier = ">=1.1.410" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "ruff", specifier = ">=0.15.19" },
@@ -131,6 +135,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/89/fa7f81db6134caa455c9351373ee934b5fdeed212eb939d6c42d80d683d7/ansible_runner-2.4.3-py3-none-any.whl", hash = "sha256:cdac6daa151a50084ffda710e769db23fa975fc0507796191d7708831b286e37", size = 80257, upload-time = "2026-03-16T18:42:13.72Z" },
]
[[package]]
name = "asttokens"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
@@ -165,7 +178,7 @@ wheels = [
[[package]]
name = "copier"
version = "9.16.0"
version = "9.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
@@ -183,9 +196,9 @@ dependencies = [
{ name = "questionary" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/c7/3cd18cd539ff41e8e7a95b0146dbace0ba332a4c97f468066b2c2daca2ed/copier-9.16.0.tar.gz", hash = "sha256:4db1a9861d0760f745cc6241f99be37b476f849b8ad700133e2f620b7df92eb2", size = 643997, upload-time = "2026-06-23T17:09:04.995Z" }
sdist = { url = "https://files.pythonhosted.org/packages/08/6a/73ccc2ce53c17a29d650246a625df6342f74cb9d4f15b78a77d311fc6007/copier-9.17.0.tar.gz", hash = "sha256:d966b043a15c74595f7904a6af89f3291135682f8313c4b71ef368811ed554f2", size = 645743, upload-time = "2026-07-13T14:33:04.838Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/a4/cf8df35b6488c04cae9aa7338c7b531a87aae2515da3993d2906d49b1dc1/copier-9.16.0-py3-none-any.whl", hash = "sha256:fadc55a22db6fb0f99d52878d3df4df3fa9c314dd96b0d81fef785a5e803114b", size = 65483, upload-time = "2026-06-23T17:09:03.617Z" },
{ url = "https://files.pythonhosted.org/packages/22/2c/68dec88ee26f96af2d4d8ee9d8567313a0826f4fd6bc5ae4527be670d354/copier-9.17.0-py3-none-any.whl", hash = "sha256:fe4e7b59faf4c0e1386eccbb79c6797e6df33ca20d5de49a8372571d8669f2ad", size = 65954, upload-time = "2026-07-13T14:33:03.301Z" },
]
[[package]]
@@ -226,6 +239,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "decorator"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
]
[[package]]
name = "dunamai"
version = "1.26.1"
@@ -250,6 +272,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "executing"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "funcy"
version = "2.0"
@@ -273,14 +304,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.50"
version = "3.1.55"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/ab/ba0d29f2fa2277ed6256b2ac09003494045355f3a10bf32f351761287870/gitpython-3.1.55.tar.gz", hash = "sha256:781e3b1624dad81b24e9524bf0297b69786a0706db2cbceec1e2b05c38e5152f", size = 225071, upload-time = "2026-07-23T02:52:43.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
{ url = "https://files.pythonhosted.org/packages/20/6a/d3b8208d2f8aac66abe8ccc1c23fa2c89464ec42cc71a601e95d05902428/gitpython-3.1.55-py3-none-any.whl", hash = "sha256:7c9ec1e69c158c081632ab35c41471e302c96db2ae42165036a5d2403378812e", size = 216590, upload-time = "2026-07-23T02:52:41.932Z" },
]
[[package]]
@@ -310,6 +341,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ipython"
version = "8.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "decorator" },
{ name = "exceptiongroup" },
{ name = "jedi" },
{ name = "matplotlib-inline" },
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit" },
{ name = "pygments" },
{ name = "stack-data" },
{ name = "traitlets" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" },
]
[[package]]
name = "jedi"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parso" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -375,6 +440,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
]
[[package]]
name = "matplotlib-inline"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -424,6 +501,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "parso"
version = "0.8.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
@@ -447,11 +533,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.10.0"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
{ url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
]
[[package]]
@@ -465,14 +551,14 @@ wheels = [
[[package]]
name = "plumbum"
version = "2.0.1"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/6a/1d1b143420fcdfc8902f2db6b7d1d2325211461c5f2a43c849de7afad688/plumbum-2.0.1.tar.gz", hash = "sha256:61623f856dcb09eb20dcd5aa708dfb3cd04b6f4ab10224d39303b163bb1c4c61", size = 377668, upload-time = "2026-06-08T14:44:06.17Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/d2/578712a979a50e9aba08ea173a38e7598461130d3c9899f176373bc03280/plumbum-2.0.2.tar.gz", hash = "sha256:233751d7819c9e6743ec1c2405927eb4fa52a284c7b894bd10e28106a9309a92", size = 390799, upload-time = "2026-07-22T04:13:12.157Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl", hash = "sha256:27a454980f91689aae8f18242a36daaf2636219171cf0e6a849744aa1d6fff85", size = 164460, upload-time = "2026-06-08T14:44:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl", hash = "sha256:a865771826c1d2cce0ca8b2a2c48aa0447ed7a518859e389a95f158e9c369547", size = 168804, upload-time = "2026-07-22T04:13:10.76Z" },
]
[[package]]
@@ -496,6 +582,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pure-eval"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -621,6 +716,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-daemon"
version = "3.1.2"
@@ -695,27 +802,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.20"
version = "0.15.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
{ url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" },
{ url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" },
{ url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" },
{ url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" },
{ url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" },
{ url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" },
{ url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" },
{ url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" },
{ url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" },
{ url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" },
{ url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" },
{ url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" },
{ url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" },
{ url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" },
]
[[package]]
@@ -736,6 +843,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
]
[[package]]
name = "stack-data"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asttokens" },
{ name = "executing" },
{ name = "pure-eval" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
@@ -745,9 +866,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "traitlets"
version = "5.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
]
[[package]]
name = "typer"
version = "0.26.8"
version = "0.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -755,9 +885,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
]
[[package]]
+1 -1
View File
@@ -1322,4 +1322,4 @@ caLabel[statusTip="Operation"]{
</hints>
</connection>
</connections>
</ui>
</ui>
@@ -6,8 +6,6 @@ from agebd.utils import init_logging
from agebd_{{service_name_lower_underscores}} import PVs, Service
# TODO: remove: cicd test 10
def main(
log_level: LogLevel = typer.Option(
LogLevel.INFO,
@@ -2,11 +2,10 @@ import time
from epics import dbr
from agebd.pv import LocalPVLink
from agebd.pv import LocalPVLink, get_pv_class
from agebd.service.base import BaseService
from agebd.service.pvs import BasePVs
from agebd.utils import printgetversion
from agebd.pv import get_pv_class
__version__ = printgetversion(__file__)
@@ -32,18 +31,14 @@ class PVs(BasePVs):
# Option 2:
## Service specific PVs from dedicated IOC
self.my_pv2 = PV(
"AGEBD-{{ service_name_upper }}:BO"
) # TODO: is this a good PV to use?
self.my_pv2 = PV("AGEBD-{{ service_name_upper }}:BO") # TODO: is this a good PV to use?
## Service specific PVs from other IOCs
# ...
# TODO: class attribute names usually lowercase
# usually it is advisable to trigger the mainloop of a service on changes of certain PVs:
self.CallbackPV = PV(
"AGEBD-{{ service_name_upper }}:CALLBACK", auto_monitor=dbr.DBE_VALUE
) # TODO: ok?
self.CallbackPV = PV("AGEBD-{{ service_name_upper }}:CALLBACK", auto_monitor=dbr.DBE_VALUE)
class Service(BaseService[PVs]):