Repository navigation
DEVELOPMENT
pyproject.toml is the project's standard Python packaging and tool
configuration file. It tells installers how to build the project, which
dependencies it needs, how to expose the command-line program, and where the
source code, templates, and tests are located.
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"The project uses setuptools as its build backend. When building or installing
the package, Python's build frontend first makes setuptools version 68 or newer
available, then asks setuptools.build_meta to build it.
The [project] table defines the distribution name (playbook-scheduler),
version, short description, README used as package documentation, and minimum
Python version (3.10).
The dependencies list contains packages installed for every use of
Playbook Scheduler:
-
APScheduler>=3.10,<4provides scheduled execution. The upper bound keeps the project on the APScheduler 3.x API. -
Jinja2>=3.1renders the HTML report. -
PyYAML>=6.0reads the YAML configuration.
Version constraints specify a minimum acceptable version. APScheduler also has an explicit maximum; the Jinja2 and PyYAML dependencies do not.
[project.optional-dependencies]
dev = [
"pylint>=4.1.2",
"pytest>=9.1.1",
"ruff==0.16.10",
"yamllint==1.38.0",
]The dev extra adds pytest for tests, Ruff for Python linting, and yamllint
for YAML linting. Install the package and development tools in the active
environment with:
python -m pip install -e ".[dev]"The -e flag installs the package in editable mode, so changes under src/
are used without reinstalling after every edit. ansible-core provides the external ansible-playbook executable and remains
separate from the development tools. It is pinned in requirements.txt for the
default Ansible executable setup; jobs using their own ansible_venv can use
Ansible installed in that project environment instead.
[project.scripts]
playbook-scheduler = "playbook_scheduler.cli:main"This creates the playbook-scheduler command when the package is installed. It
calls the main function in src/playbook_scheduler/cli.py.
The setuptools configuration maps the Python package root to src/ and tells
setuptools to discover packages there. Consequently, the importable package is
playbook_scheduler, even though the project is laid out using the common src/
directory convention.
The package-data setting includes *.j2 files under
src/playbook_scheduler/templates/ in built distributions. These files contain
the Jinja2 HTML report template and must be present for report generation to
work after installation.
The [tool.pytest.ini_options] table makes tests/ the default test
directory and adds src/ to pytest's import path. This lets tests import
playbook_scheduler directly from the source tree, including when the project has
not been installed.
Run the tests with:
python -m pytestThe files under tests/ are automated pytest tests for Playbook Scheduler itself.
They check that the code still behaves correctly after changes to the report
template, the configuration handling, the scheduler, and so on. They do not
test your playbooks or hosts: every test runs in a temporary directory, and
Ansible is never actually started.
| File | Covers |
|---|---|
test_config.py |
Loading the configuration: path resolution, invalid cron expressions, missing playbooks, ansible_venv
|
test_runner.py |
Running a job: success, non-zero exit code, timeout and stopping Ansible, virtual environment |
test_reporting_retention.py |
HTML report (escaping, filters, skipped runs, shortened output) and removal of expired runs |
test_cli.py |
Error output of the command-line interface, ansible-playbook check in validate
|
test_shutdown.py |
Stopping running jobs and the scheduler on SIGTERM/SIGINT |
test_scheduler.py |
Automatic reload of the configuration while serve is running |
test_concurrency.py |
Lock files, skipping a job that is already running, atomic writes, max_parallel_jobs
|
From the project directory, with the virtual environment activated and the
dev extra installed:
source .venv/bin/activate
pytest # all tests
pytest -v # show the name of every test
pytest tests/test_config.py # a single file
pytest -k reload # only tests whose name contains "reload"
pytest -x # stop at the first failureDots or PASSED mean the tests succeeded. On FAILED, pytest shows which
assert failed and the values involved.
- Select
.venvas the Python interpreter (Ctrl+Shift+P→ "Python: Select Interpreter"). - Open the Testing view (flask icon) in the sidebar and, when prompted,
choose pytest and the
testsdirectory. - Run all tests or individual ones from there. Every
def test_…line in a test file also gets a ▶ button; right-click it and choose "Debug Test" to stop at breakpoints.
pytest automatically collects every test_* function in tests/test_*.py.
A test usually consists of one or more assert statements. Add a tmp_path
parameter to receive an empty temporary directory for files the test creates.
tests/test_config.py is a good starting point.
Run pytest before every commit.
Several Ansible runs can be active at the same time: the scheduler runs
different jobs in parallel, and playbook-scheduler run can be started while
serve is running. All of them share the same runs_directory and
reports_directory. The rules below keep them from getting in each other's
way.
run_scheduler in src/playbook_scheduler/scheduler.py creates the APScheduler
BlockingScheduler with two thread pools (executors):
| Executor | Threads | Runs |
|---|---|---|
default |
max_parallel_jobs (default 10) |
The configured Ansible jobs |
config_watcher |
1 | The configuration check every 30 seconds |
Every job runs ansible-playbook as a child process from one thread of the
default pool, so up to max_parallel_jobs different jobs run at once. When
all threads are busy, a due job waits for a free thread. If it waits longer
than misfire_grace_time (one hour), it is dropped for this fire time. The
watcher has its own pool so that long playbook runs cannot delay reloading the
configuration. Thread pools cannot be resized while running, so a changed
max_parallel_jobs only logs a warning and takes effect after a restart.
Each job is registered with these APScheduler options:
-
max_instances=2: one instance runs Ansible. The second instance exists only so that a fire time during a running job reachesexecute_job, where the job lock rejects it and askippedrun is recorded. Withmax_instances=1, APScheduler would drop that fire time itself and only log a warning, so the report would not show it. -
coalesce=Trueandmisfire_grace_time=3600: fire times that were missed, for example because the process was blocked or restarting, are combined into one run if they are at most one hour late.
The scheduled function is run_scheduled_job. It logs the start of the job,
calls execute_job, and logs the result: INFO for success, ERROR for any
other status. JobAlreadyRunning becomes a single WARNING line
Skipped scheduled run: … instead of a traceback.
The configuration watcher has misfire_grace_time set to its interval. With
APScheduler's default of one second, a check that started slightly late would
log a "was missed" warning.
APScheduler's max_instances only works inside one process and would drop
the second run without a record. To prevent parallel runs of a job in all
cases, including playbook-scheduler run and a second serve process, src/playbook_scheduler/files.py provides
file_lock, an exclusive lock on a file under runs_directory/.locks/:
| Lock file | Mode | Held while |
|---|---|---|
<job name>.lock |
Non-blocking |
ansible-playbook is running for that job |
_report.lock |
Blocking | Retention and report generation run |
-
Job lock (
execute_jobinservice.py): if the lock is already held,file_lock(..., blocking=False)raisesLockBusyimmediately, andexecute_jobrecords a run with statusskipped(record_skipped_runinrunner.py), refreshes the report, and raisesJobAlreadyRunning. The run is not queued.playbook-scheduler runexits with code3. -
Report lock (
refresh_reportinservice.py): when two jobs finish at the same time, the second one waits until the first has finished removing expired runs and writing the report. Without it, one thread could delete an old run file while another is reading it, which would fail withFileNotFoundError. The job lock is released before the report lock is taken, so a run never holds both.
The lock name _report starts with an underscore and can therefore not
collide with a job name, which must start with a letter or number.
How file_lock works:
- On Linux and macOS it uses
fcntl.flock, on Windowsmsvcrt.lockingon the first byte of the file. Waiting for a blocking lock polls every 0.1 seconds. - Each call opens its own file handle. These locks belong to the handle, not to the process, so they also exclude other threads of the same process.
- The operating system releases the lock when the process ends, including on a
crash or
kill -9. A lock file left behind therefore does not block later runs. - Lock files are intentionally never deleted. If one process deleted the file while another waited on it, a third process would create a new file and receive a second, independent lock.
-
flockcan be unreliable on network file systems such as NFS. Keepruns_directoryon a local file system if several processes use it.
atomic_write_text in files.py first writes into a temporary file in the
target directory and then replaces the target with os.replace. Readers, such
as a browser opening reports/index.html or load_runs reading run JSON
files, therefore always see either the complete old file or the complete new
file, never a partially written one. Run records (runner.py) and the HTML
report (reporting.py) both use it. Temporary files start with a dot and end
in .tmp, so *.json globs never pick them up.
run_job in src/playbook_scheduler/runner.py starts ansible-playbook with
subprocess.Popen and waits with communicate(timeout=timeout_seconds). When
the timeout expires, _stop_process stops the process in two steps:
- Send SIGTERM (
process.terminate()) and wait up toSTOP_GRACE_SECONDS(30 seconds). - Only if Ansible has not exited by then, send SIGKILL (
process.kill()).
The order matters. Ansible runs every worker process in its own session, so
the workers and the tasks they started are not part of ansible-playbook's
process group. On SIGTERM, ansible-playbook stops its workers and their
tasks itself. On SIGKILL it cannot clean up, and the workers keep running the
current task on the managed hosts after the run has already been recorded as
timed out. subprocess.run(timeout=...), which Playbook Scheduler used before, sends
SIGKILL immediately and therefore left such processes behind.
The output produced until the stop is kept in stdout and stderr of the run
record, return_code stays null, and error says Timed out after … seconds.
The same stop sequence runs if waiting is interrupted, for example by Ctrl+C
during playbook-scheduler run; the interruption is then raised again.
run_scheduler registers StopSignalHandler (scheduler.py) for SIGTERM and
SIGINT. Python runs signal handlers in the main thread, which in serve only
runs the APScheduler loop; the jobs run in the thread pool.
On the first signal the handler:
- Calls
stop_running_jobs()inrunner.py. It sets the stop flag and sends SIGTERM to everyansible-playbookprocess in_running_processes. As with the timeout, SIGTERM lets Ansible end its workers and tasks. - Calls
scheduler.shutdown(wait=False), so no new runs start andscheduler.start()returns.
Back in run_scheduler, jobs_executor.shutdown(wait=True) waits until every
job thread has finished. Each interrupted run returns from communicate(),
sees the stop flag, is recorded as failed with Interrupted by shutdown., and
refreshes the report. Then serve exits with code 0. A run that exits with
code 0 at the same moment is recorded as successful as usual.
A second signal calls stop_running_jobs(force=True), which sends SIGKILL to
processes that ignored SIGTERM. That may leave Ansible's workers behind, which
is why it is not the first step.
Details:
-
run_jobadds every started process to_running_processesunder_running_lockand removes it when it ends. A process that starts just afterstop_running_jobs()went through the set sees the stop flag when it is added and is terminated right away. - The handler does not wait itself. Waiting in a signal handler would block the main thread and could be interrupted by the next signal.
- With systemd,
KillMode=mixed(seeREADME.md) makes sure only Playbook Scheduler receives SIGTERM. With the defaultcontrol-group, systemd also sends SIGTERM to Ansible directly. Ansible may then exit before Playbook Scheduler has set its stop flag, and the run is recorded as a normal failure with exit code-15.
find_ansible_playbook in config.py returns the executable a job would run:
with ansible_venv the one inside that environment, otherwise the first
ansible-playbook on PATH, found with shutil.which. which also checks
that the file is executable. validate in cli.py prints the path per job,
or an error, and exits with code 2 if any job has no executable.
load_config does not run this check. Otherwise every test and every
configuration reload would need an Ansible installation, and serve would
reject a configuration only because PATH differs.
tests/test_concurrency.py covers the lock behavior, a second run of a busy
job in execute_job including its skipped record, the CLI and the
scheduler, logging, atomic writes, and the max_parallel_jobs and
report_output_lines settings.
serve calls configure_logging() in scheduler.py before the scheduler
starts. It configures the root logger with logging.basicConfig:
- Format
%(asctime)s %(levelname)s %(message)s, date format%Y-%m-%d %H:%M:%S%z, which is local time with UTC offset. - Level
INFO, output to standard error. - The
apschedulerlogger is set toWARNING. AtINFO, APScheduler logs every execution, including the configuration check every 30 seconds. Warnings such as missed fire times still appear.
The modules log through logging.getLogger(__name__). Only serve configures
logging. validate, run, and report are interactive and keep writing
plain text to standard output and standard error with print.
The signal handler also logs. Python runs it in the main thread between two bytecode instructions; logging handlers use a reentrant lock, so this cannot deadlock in the main thread.
The run records keep the complete output. Only the HTML report is shortened,
by tail_output in reporting.py:
- Keep the last
report_output_lineslines (default200). - Cut every remaining line after
MAX_LINE_CHARS(1000) characters and mark it with…[line cut]. With-v, Ansible prints each task result as a single JSON line, which a line limit alone would not shorten.
generate_report passes tail_output with the configured number of lines to
the template as tail. The template shows a note with the number of omitted
lines and the file name of the JSON record. load_runs adds that name as
record_file to every loaded record; it is not stored in the JSON file.
The output is the main contributor to the report size: stdout of each run of
the last retention_days days, all in one file.
To garanty good code quality we use:
ruff → Formatting and various style checks
pylint → Architecture and code quality checks
pytest → Tests
yamllint → YAML files
Python code linter
pylint $(git ls-files '*.py')Run the python tests
pytestCheck the code locally with Ruff:
ruff check .Besides the default rules, pyproject.toml enables:
-
D(pydocstyle): every module, class, and function undersrc/needs a docstring following PEP 257. The summary line is in the imperative mood ("Return …", not "Returns …"); arguments, return values, yielded values, and raised exceptions are described in Google-styleArgs:,Returns:,Yields:, andRaises:sections. -
ANN(flake8-annotations): every function needs PEP 484 type hints for all parameters and the return value. Types that are only needed for annotations, such as Ansible'sTaskResultin the callback plugin, are imported underif TYPE_CHECKING:so they are not imported at runtime.
Tests under tests/ are exempt from both rules.
Check the YAML files with yamllint:
yamllint config/ src/If you are working in a WSL you need to create a web server like this.
python3 -m http.server 8000 --directory reportsThen open in the Windows browser http://localhost:8000 . WSL2 does a forwarding of localhost to the Windows system.