Skip to content

fix(pyats): display clean relative test names instead of absolute paths (#653)#655

Merged
oboehmer merged 15 commits intomainfrom
fix/653-test-name-display
Mar 25, 2026
Merged

fix(pyats): display clean relative test names instead of absolute paths (#653)#655
oboehmer merged 15 commits intomainfrom
fix/653-test-name-display

Conversation

@oboehmer
Copy link
Collaborator

@oboehmer oboehmer commented Mar 16, 2026

Description

Fix PyATS progress reporter to display clean relative test names when the templates directory doesn't contain a tests subdirectory.

Previously, when parts.index("tests") failed, the code fell back to using the entire absolute path, resulting in output like:

EXECUTING /.Users.oboehmer.Documents.DD.project.templates.verify_device

Now test names are computed relative to the templates_dir passed to the orchestrator:

EXECUTING verify_device

This is a leftover from the pre-#511 era when PyATS tests were required to be under a tests/ directory.

Symlink safety: absolute() instead of resolve() (#656)

A secondary but important fix applied throughout the PyATS stack: all Path.resolve() calls used for path comparison (i.e. relative_to()) have been replaced with Path.absolute().

Why this matters: resolve() dereferences symlinks to their real filesystem location. When a test file inside test_dir is itself a symlink whose target lives outside test_dir, resolve() returns the target path — and relative_to(test_dir) then raises ValueError, causing the test name to fall back to the bare stem or the full absolute path.

absolute() makes the path absolute without following symlinks, so the logical structure under test_dir is preserved and relative_to() works correctly regardless of where symlink targets live.

Affected files: orchestrator.py, job_generator.py, test_discovery.py, test_type_resolver.py, plugin.py, archive_inspector.py.

Closes

Closes #706, Closes #707, Closes #708, Closes #709, Closes #710, Closes #711, Closes #712, Closes #713, Closes #714, Closes #715

Related Issue(s)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactoring / Technical debt (internal improvements with no user-facing changes)
  • Documentation update
  • Chore (build process, CI, tooling, dependencies)
  • Other (please describe):

Test Framework Affected

  • PyATS
  • Robot Framework
  • Both
  • N/A (not test-framework specific)

Network as Code (NaC) Architecture Affected

  • ACI (APIC)
  • NDO (Nexus Dashboard Orchestrator)
  • NDFC / VXLAN-EVPN (Nexus Dashboard Fabric Controller)
  • Catalyst SD-WAN (SDWAN Manager / vManage)
  • Catalyst Center (DNA Center)
  • ISE (Identity Services Engine)
  • FMC (Firepower Management Center)
  • Meraki (Cloud-managed)
  • NX-OS (Nexus Direct-to-Device)
  • IOS-XE (Direct-to-Device)
  • IOS-XR (Direct-to-Device)
  • Hyperfabric
  • All architectures
  • N/A (architecture-agnostic)

Platform Tested

  • macOS (version tested: darwin)
  • Linux (distro/version tested: )

Key Changes

  • orchestrator.py: Set NAC_TEST_TEST_DIR environment variable for API tests; pass test_dir to ArchiveInspector; use absolute() instead of resolve() for test_dir and base_output_dir to preserve symlinks
  • device_executor.py: Set NAC_TEST_TEST_DIR environment variable for D2D tests
  • plugin.py: Use NAC_TEST_TEST_DIR to compute relative test names via Path.absolute().relative_to(); removed dead "templates" fallback code; replaced resolve() with absolute()
  • archive_inspector.py: Accept test_dir as required parameter instead of searching for hardcoded "tests" directory in path; narrowed except (ValueError, Exception) to except ValueError; replaced resolve() with absolute()
  • job_generator.py: Replace resolve() with absolute() when embedding test file paths in generated job scripts
  • test_discovery.py: Replace resolve() with absolute() for exclude paths and discovered test files
  • test_type_resolver.py: Replace resolve() with absolute() for test_root and cache key normalisation
  • test_e2e_scenarios.py: Added E2E test test_stdout_pyats_test_names_are_relative() validating relative test names using section markers to isolate PyATS output from pabot; added symlink fixture (pyats_api_only) to exercise the absolute() fix

Testing Done

  • Unit tests added/updated
  • Integration tests performed
  • Manual testing performed:
    • PyATS tests executed successfully
    • Robot Framework tests executed successfully
    • D2D/SSH tests executed successfully (if applicable)
    • HTML reports generated correctly
  • All existing tests pass (pytest / pre-commit run -a)

Test Commands Used

uv run pytest -n auto --dist loadscope

Checklist

  • Code follows project style guidelines (pre-commit run -a passes)
  • Self-review of code completed
  • Code is commented where necessary (especially complex logic)
  • Documentation updated (if applicable)
  • No new warnings introduced
  • Changes work on both macOS and Linux
  • CHANGELOG.md updated (if applicable)

Screenshots (if applicable)

Before (when templates dir has no tests/ subdirectory):

[PID:81951] [81870] [ID:3] EXECUTING /.Users.oboehmer.Documents.DD.project.templates.verify_device

After:

[PID:81951] [81870] [ID:3] EXECUTING verify_device

Additional Notes

The fix passes test_dir (the templates path where tests are discovered) from the orchestrator to the plugin subprocess via the NAC_TEST_TEST_DIR environment variable. This enables proper relative path computation regardless of directory structure, eliminating the hardcoded dependency on a tests/ subdirectory.

The absolute() over resolve() change is applied consistently across the entire PyATS stack wherever paths are used for relative_to() comparisons. The E2E fixture for pyats_api_only includes a symlink that points outside test_dir to directly exercise this code path.

The E2E test uses section markers (Running PyATS testsRunning Robot Framework tests) to extract only PyATS output, avoiding false positives from pabot messages that use a similar format.

…hs (#653)

PyATS test names now show relative paths (e.g., "nrfu.verify_device") instead
of full absolute paths. The fix passes test_dir from the orchestrator to the
plugin subprocess via NAC_TEST_TEST_DIR environment variable, enabling proper
relative path computation.

- orchestrator.py: Set NAC_TEST_TEST_DIR env var for API tests, pass test_dir
  to ArchiveInspector
- device_executor.py: Set NAC_TEST_TEST_DIR env var for D2D tests
- plugin.py: Use NAC_TEST_TEST_DIR to compute relative test names; remove dead
  "templates" fallback code
- archive_inspector.py: Accept test_dir as required parameter; fix redundant
  exception handling and docstring
- test_e2e_scenarios.py: Add E2E test validating relative test names using
  section markers to isolate PyATS output
Replace resolve() with absolute() throughout the pyats_core stack so that
symlinked test files are not dereferenced before relative_to() comparisons.
resolve() follows symlinks to their target, which breaks the path prefix check
when the target lives outside test_dir — exactly the case for the new
pyats_api_only e2e fixture where the test file is a symlink into a parent dir.

Changed files:
- orchestrator.py: absolute() for test_dir and base_output_dir
- plugin.py: absolute() in _get_test_name()
- archive_inspector.py: absolute() in _derive_test_name_from_path()
- job_generator.py: absolute() when embedding test file paths in job scripts
- test_discovery.py: absolute() for exclude_paths and discovered test_files
- test_type_resolver.py: absolute() for test_root and cache key normalisation

Also incorporates review fixes from #653:
- plugin.py: drop the hardcoded "templates" title fallback; reuse the
  already-computed test_name (relative dot-name, or bare stem as fallback)
- archive_inspector.py: narrow broad `except (ValueError, Exception)` to
  `except ValueError` — the only exception relative_to() raises
- archive_inspector.py: fix stale "Optional" wording in extract_test_results()
  docstring; test_dir is now a required parameter

E2E fixture addition:
- Move verify_aci_apic_appliance_operational_status.py to the pyats_api_only
  fixture root and replace it with a symlink inside templates/tests/, so the
  e2e suite exercises the absolute()-vs-resolve() path during a real run

Test fix:
- test_test_type_resolver.py: add mock.absolute.return_value = mock to
  _create_mock_path() to match the resolve() → absolute() refactor; drop
  the now-unused mock.resolve.return_value = mock line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@oboehmer oboehmer marked this pull request as draft March 17, 2026 09:31
- Enhanced plugin.py logging to show absolute path when relative_to() fails
  This helps debug edge cases where path resolution behaves unexpectedly

- Stricter regex pattern in E2E tests to require test names start with word char
  Pattern changed from r'[\w.-]+' to r'\w[\w.-]*' to reject leading dots/hyphens

Addresses code review feedback on PR #655
@oboehmer oboehmer marked this pull request as ready for review March 17, 2026 10:18
@oboehmer oboehmer requested a review from aitestino March 17, 2026 10:19
The license-headers.sh script previously rejected symlinks with an error,
causing pre-commit checks to fail when symlinked test files were added.

Changes:
- Modified validate_file() to return code 2 for symlinks instead of error
- Updated file processing loop to skip symlinks with a warning message
- Symlinks are now skipped silently since their targets are checked independently
- Uses || pattern to work around bash set -e behavior with non-zero returns

This allows the E2E test fixtures to use symlinks (e.g., for testing the
absolute() vs resolve() path handling in PR #655) without breaking CI.
Copy link
Collaborator

@aitestino aitestino left a comment

Choose a reason for hiding this comment

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

Hey @oboehmer, thank you for the PR — this is a solid fix for the absolute-path test names in PyATS output.

Per our offline conversation, we're approving this PR with follow-up tracking issues for a few refinements identified during review. Nothing here blocks merge — they're all incremental improvements we can pick up after.

Opened tracking issues:

  • #706 — refactor: extract shared test-name computation to path_utils.py (prio: medium) — plugin.py::_get_test_name() and archive_inspector.py::_derive_test_name_from_path() duplicate the same relative_to() → dot-notation logic
  • #707 — refactor: extract NAC_TEST_TEST_DIR to named constant (prio: low) — the env var string appears in 4+ files as a bare literal
  • #708 — tests: unit tests for ProgressReporterPlugin._get_test_name() (prio: high) — the core bug-fix method has zero unit test coverage
  • #709 — tests: unit tests for NAC_TEST_TEST_DIR env var propagation (prio: medium) — verify orchestrator and device_executor actually set the env var for subprocesses
  • #710 — perf: cache test_dir_path in plugin (prio: low) — _get_test_name() re-parses and re-resolves the env var on every call
  • #711 — fix: validate NAC_TEST_TEST_DIR before use (prio: medium) — no check for empty string, non-existent path, or non-directory values
  • #712 — refactor: consistent error handling between plugin and archive_inspector (prio: medium) — plugin logs a warning + falls back on ValueError, archive_inspector lets it propagate; related to #706
  • #713 — chore: clarify resolve() vs absolute() for MERGED_DATA_MODEL path (prio: low) — orchestrator switched most paths to absolute() but kept resolve() for the merged data model filepath
  • #714 — fix: harden license-headers.sh symlink handling (prio: low) — the set -e + || workaround for symlink skipping is fragile
  • #715 — tests: unit test for symlinked test file in archive_inspector (prio: medium) — the archive inspector path now uses absolute() but has no symlink-specific test coverage

None of these block the PR — they're all incremental refinements that can be addressed in follow-up PRs. Nice work on this one. 👍

P.S. — This comment was drafted using voice-to-text via Claude Code. If the tone comes across as overly direct or terse, please know that's just how it tends to phrase things. No offense or criticism is intended — this is purely an objective technical review of the PR. Thanks for understanding! 🙂

oboehmer and others added 10 commits March 24, 2026 07:28
Replace bare "NAC_TEST_TEST_DIR" string literals in plugin.py,
orchestrator.py and device_executor.py with a single named constant
ENV_TEST_DIR defined in nac_test/pyats_core/constants.py.

Closes #707

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_get_test_name() was re-reading os.environ and re-constructing the
Path object on every pre_task/post_task call. Resolve and store
test_dir_path once at plugin initialisation (env var is static during
a PyATS job). Also move the "env var not set" warning to __init__ so
it fires once rather than once per test.

Closes #710

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the plugin accepted any non-empty string from the env var
and would silently fall back on ValueError from relative_to(). Now the
cached test_dir_path is validated at plugin initialisation:

- Non-existent path or non-directory → warning + None (filename-only fallback)

These are internal bug-guards only; neither condition should fire in
production since test_dir is Typer-validated and ENV_TEST_DIR is set
exclusively by our orchestrator/device_executor.

Closes #711

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_utils.py

plugin.py::_get_test_name() and archive_inspector.py::_derive_test_name_from_path()
duplicated the same relative_to() → dot-notation logic. Extract it to a single
derive_test_name() function in nac_test/utils/path_utils.py and update both
callers to use it. Remove the now-redundant _derive_test_name_from_path() static
method from ArchiveInspector.

Move TestDeriveTestNameFromPath tests from test_archive_inspector.py to a new
tests/unit/utils/test_path_utils.py targeting derive_test_name() directly.

Closes #706

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both callers of derive_test_name() silently fell back on path mismatch.
Add a debug log in derive_test_name() itself so both the plugin and
archive_inspector get consistent visibility without duplication.

Closes #712

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add tests/pyats_core/progress/test_plugin.py covering:
- dot-notation derivation for one-level, nested, and root-level paths
- stem-only fallback when ENV_TEST_DIR is unset
- stem-only fallback when script is outside test_dir
- caching: ENV_TEST_DIR changes after __init__ have no effect

Uses the existing pyats_test_dirs fixture for directory setup.

Closes #708

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that both the orchestrator (_execute_api_tests_standard) and
DeviceExecutor (run_device_job_with_semaphore) include ENV_TEST_DIR in
the environment dict passed to the subprocess runner.

TestOrchestratorEnvPropagation is placed alongside the existing
TestOrchestratorEnvVarProcesses in test_orchestrator_env_var.py.
TestDeviceExecutorEnvPropagation lives in its own module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that derive_test_name handles a symlinked test_dir correctly.
absolute() preserves symlinks on both sides of the relative_to
comparison, whereas resolve() would follow them and could break
the path matching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the fragile `|| validate_result=$? / validate_result=${validate_result:-0}`
double-assignment pattern with a clean if/else block. No behaviour change;
set -e protection is preserved everywhere else.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
)

Consistent with the rest of the codebase which uses absolute() for
path-to-env-var conversions. resolve() is intentionally kept only where
symlink resolution is semantically required (e.g. archive_security.py).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@oboehmer
Copy link
Collaborator Author

thanks for the review, @aitestino .. resolved all issues.

@oboehmer oboehmer merged commit 92c45e6 into main Mar 25, 2026
11 checks passed
@oboehmer oboehmer deleted the fix/653-test-name-display branch March 25, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment