Package-Based Job References¶
This document describes the design for referencing test automation jobs from installed Python packages, enabling organizations to maintain reusable job libraries that can be shared across multiple projects.
Motivation¶
As test automation matures, organizations accumulate large libraries of reusable jobs - OSPF neighbor verification, BGP peering checks, interface status validation, and so on. These jobs are not specific to any one project or testbed; they encode general validation logic that applies across environments.
Currently, jobs are referenced as file paths relative to the project root:
This means every project that needs OSPF validation must either copy the job file or maintain a shared directory. Neither scales well:
- Copy-paste leads to drift - bug fixes in one copy don't propagate to others
- Shared directories create tight coupling and awkward path management
- Version pinning is impossible - there's no way to say "use v2.1 of the OSPF checks"
Solution: Python Package References¶
Jobs from installed Python packages are referenced using dot-delimited module paths instead of file paths:
test_cases:
"1.0.0":
title: Verify OSPF Neighbors
job: huginn_jobs_network.ospf.verify_neighbors
"1.1.0":
title: Verify BGP Peering
job: huginn_jobs_network.bgp.verify_peering
"2.0.0":
title: Verify Interface Status
job: jobs/verify_interface_status.py # Local job - file path still works
The framework detects whether a job value is a file path or a module path and handles each accordingly.
Detection Logic¶
The framework distinguishes between local file paths and package module paths:
- File path: Contains
/or ends with.py- resolved relative to the project root - Module path: Dot-delimited identifier with no
/and no.pysuffix - resolved via Python's import system
jobs/verify_ospf.py → file path (contains /)
jobs.verify_ospf → module path (dots only)
huginn_jobs_network.ospf.verify → module path (dots only)
The :ClassName suffix works with both forms:
job: jobs/verify_ospf.py:VerifyOSPFNeighbors # File path + explicit class
job: huginn_jobs_network.ospf.verify:VerifyOSPFNeighbors # Module path + explicit class
When no class is specified, the framework loads the first concrete TestCase (or LearningTestCase) subclass found in the module, consistent with the existing behavior for file-based jobs.
Advantages Over Git References¶
An earlier design considered referencing jobs via git repository URLs and refs. The package-based approach is simpler and more robust:
| Concern | Git References | Package References |
|---|---|---|
| Version pinning | ref: v2.1.0 (custom) |
huginn-jobs-network>=2.1.0,<3.0.0 (standard) |
| Dependency resolution | Manual, no conflict detection | Handled by uv/pip |
| Installation | Clone at runtime | Pre-installed via uv sync |
| Offline support | Requires network at execution | Works after install |
| IDE support | None (code not in environment) | Full (autocomplete, type checking) |
| Reproducibility | Depends on git state | Lock file guarantees |
Job Package Structure¶
A job package is a standard Python package that contains LearningTestCase subclasses. There is no special framework-level interface required - any installable package with importable job modules works.
Example Package Layout¶
huginn-jobs-network/
├── pyproject.toml
├── src/
│ └── huginn_jobs_network/
│ ├── __init__.py
│ ├── ospf/
│ │ ├── __init__.py
│ │ ├── verify_neighbors.py
│ │ ├── verify_interfaces.py
│ │ └── verify_routes.py
│ ├── bgp/
│ │ ├── __init__.py
│ │ ├── verify_peering.py
│ │ └── verify_routes.py
│ └── interfaces/
│ ├── __init__.py
│ └── verify_status.py
Package Configuration¶
# huginn-jobs-network/pyproject.toml
[project]
name = "huginn-jobs-network"
version = "2.1.0"
description = "Network validation jobs for Huginn"
requires-python = ">=3.11"
dependencies = [
"huginn>=1.0.0,<2.0.0",
"muninn>=1.0.0",
]
Job Implementation¶
Jobs in packages are identical to local jobs - they inherit from LearningTestCase and follow the same patterns:
# src/huginn_jobs_network/ospf/verify_neighbors.py
"""OSPF neighbor validation against learned baseline."""
from typing import TypedDict
import muninn
from huginn import CommandSupportResult, Context, LearningTestCase, ResultStatus
from huginn.utils.commands import is_command_unsupported
mn = muninn.Muninn()
mn.load_local_parsers()
# ... TypedDict definitions, message templates, LearningTestCase subclass
# Exactly the same structure as a local job.
Installing in a Project¶
# Project's pyproject.toml
[project]
dependencies = [
"huginn>=1.0.0",
"huginn-jobs-network>=2.1.0,<3.0.0",
]
Test Plan Usage¶
Mixing Local and Package Jobs¶
A single test plan can reference both local file-based jobs and package-based jobs:
test_cases:
# Package jobs - shared across projects
"1.0.0":
title: Verify OSPF Neighbors
job: huginn_jobs_network.ospf.verify_neighbors
"1.1.0":
title: Verify BGP Peering
job: huginn_jobs_network.bgp.verify_peering
# Local jobs - project-specific
"3.0.0":
title: Apply OSPF Configuration Change
job: jobs/apply_ospf_change.py
"3.1.0":
title: Verify Custom Business Logic
job: jobs/verify_custom_logic.py
Validation¶
The framework validates job references at startup:
- File paths: Checks that the file exists on disk (current behavior)
- Module paths: Checks that the module is importable and contains a concrete
TestCasesubclass
Both types of validation errors are reported before any tests execute, consistent with the fail-fast principle.
Versioning and Compatibility¶
Pinning Job Package Versions¶
Standard Python dependency specifiers control which version of a job package is used:
dependencies = [
# Pin to compatible range
"huginn-jobs-network>=2.1.0,<3.0.0",
# Or pin exactly for maximum reproducibility
"huginn-jobs-network==2.1.0",
]
The uv.lock file ensures reproducible installs across environments.
Framework Compatibility¶
Job packages declare their Huginn framework dependency:
If a job package requires a newer framework version than the project uses, uv/pip will report the conflict at install time - not at test execution time.
Working with Unreleased Fixes¶
A common scenario when using shared job packages: you discover bugs in multiple jobs, submit fixes via separate pull requests, and need to use those fixes in your project before they're merged and released upstream.
Python's dependency system does not support installing multiple versions of the same package simultaneously. The recommended workflow is to combine your fixes into a single branch and install from that branch temporarily.
Workflow¶
1. You have two open PRs against huginn-jobs-network:
fix/bgp-peering-bug- fixes a comparison error inbgp/verify_peering.pyfix/ospf-route-detection- fixes a parsing issue inospf/verify_routes.py
2. Create a combined branch on your fork:
cd huginn-jobs-network
git checkout main
git checkout -b combined-fixes
git merge fix/bgp-peering-bug
git merge fix/ospf-route-detection
git push origin combined-fixes
Since bug fixes to individual jobs are typically self-contained within a single file, these merges are almost always conflict-free.
3. Point your project at the combined branch:
# Project's pyproject.toml
dependencies = [
# Temporary: your fork with both fixes
"huginn-jobs-network @ git+https://github.com/yourfork/huginn-jobs-network@combined-fixes",
]
4. When fixes are released upstream, switch back:
dependencies = [
# Back to normal: released version with fixes included
"huginn-jobs-network>=2.2.0,<3.0.0",
]
Why This Works¶
- Bug fixes are localized. A fix to
bgp/verify_peering.pyand a fix toospf/verify_routes.pydon't touch the same files, so merging the branches is trivial. - Standard Python tooling. The
@ git+https://...@branchsyntax is a standard pip/uv dependency specifier. No framework-level git cloning or special machinery needed. - Lock file tracks the exact commit.
uv.lockrecords the resolved commit SHA, ensuring reproducibility even when depending on a branch. - Clean transition. When the upstream release lands, the only change is swapping the dependency line back to a version specifier.
When This Gets Harder¶
If your fixes span many files across the same modules and create merge conflicts, the combined branch requires manual conflict resolution. In practice this is rare for job-level bug fixes but more likely for structural refactors. In that case, consider submitting a single PR with both fixes rather than maintaining separate branches.
Open Questions¶
The following questions will be resolved as we build the first job package:
-
Formal package interface: Does the framework need a registration mechanism (e.g., entry points) for job packages, or is any importable module with
LearningTestCasesubclasses sufficient? -
Job discovery / browsing: Should the framework provide tooling to list available jobs from installed packages (e.g.,
huginn jobs list --package huginn-jobs-network)? -
Parser bundling: Should job packages bundle their own Muninn parsers, or depend on a separate parser package?
-
Unit test conventions: Should job packages follow the same spec-driven test harness pattern documented in the Unit Testing Automation guide?
Related Documents¶
- Test Plan Specification: Test case
jobfield definition - Architecture: Job loading and execution flow
- Unit Testing Automation: Testing patterns for jobs