Skip to content

feat(pyats): add --device-filter CLI option for D2D testing (#932) - #951

Draft
oboehmer wants to merge 9 commits into
mainfrom
feat/932-device-filter-d2d
Draft

oboehmer wants to merge 9 commits into
mainfrom
feat/932-device-filter-d2d

Conversation

@oboehmer

@oboehmer oboehmer commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Description

Introduces the --device-filter CLI option to filter devices during Direct-to-Device (D2D) testing across pyATS test execution. It adds a versatile filter engine in nac_test.utils.device_filter, CLI validation, orchestrator coordination, and resolver integration via environment serialization (NAC_TEST_DEVICE_FILTER_JSON). The filter scopes D2D device selection only; API and Robot Framework tests are unaffected by it.

Closes

Type of Change

  • New feature (non-breaking change that adds functionality)

Test Framework Affected

  • PyATS

Network as Code (NaC) Architecture Affected

  • NX-OS (Nexus Direct-to-Device)
  • IOS-XE (Direct-to-Device)
  • IOS-XR (Direct-to-Device)
  • All architectures

Platform Tested

  • macOS (version tested: Sonoma / Sequoia on arm64)
  • Linux (distro/version tested: Ubuntu 22.04 in CI)

Key Capabilities

  1. Flexible Filter Expressions:

    • Equality: hostname=leaf1, role=spine
    • Regex matching: hostname=~^sd-dc-.*, role=~(spine|leaf)
    • Negation: role!=spine, site!=lab
    • Nested dot-separated paths: bgp.asn=65001
    • Multiple filters: repeatable --device-filter CLI options evaluated as a logical AND (all criteria must match)
  2. Full Pipeline Integration:

    • Typer CLI validation via validate_device_filter() rejecting malformed expressions before test execution.
    • Serialization into NAC_TEST_DEVICE_FILTER_JSON for cross-process and subprocess communication.
    • Integration with BaseDeviceResolver (in nac-test-pyats-common) to filter inventory prior to SSH credential injection and device dictionary creation.
  3. User Experience & Diagnostics:

    • Early warning if repeated positive filters for the same scalar field are supplied (which always results in zero matched devices).
    • Early warning if --device-filter is passed without active D2D/API pyATS tests.
    • Diagnostic output in the orchestrator summarizing filtered device counts, a console warning when a valid filter matches no devices, and a hard failure (exit 2) when a filter references a field absent from the data model.

Key Design Decisions

  1. Canonical Attributes vs. Raw Data Model Attributes (ChainMap):

    • Devices in data models often use vendor/architecture-specific keys (e.g. mgmt_ip, system_ip, ipAddress).
    • BaseDeviceResolver lazily extracts canonical attributes (hostname, ip, os) and overlays them onto the raw device dictionary using collections.ChainMap.
    • This allows users to filter intuitively on standardized attributes (hostname=foo, ip=10.1.1.1) as well as arbitrary raw model keys (tags=production, role=spine, bgp.asn=65001).
  2. Authoritative Filter Engine:

    • The AST parser, operators, and matching logic live centrally in nac_test.utils.device_filter.
    • nac-test-pyats-common delegates filter evaluation to this engine, ensuring uniform semantics and backwards compatibility.
  3. Two Distinct Failure Modes, Deliberately Treated Differently:

    • Unknown filter field (the field exists on no device in the data model) is a filter definition error, not a test outcome. The user's intent is unknowable, so the run aborts via DeviceFilterError before anything executes and the CLI exits with EXIT_INVALID_ARGS (2) — the same code a malformed expression gets from the --device-filter callback. Both filter failures therefore surface identically to the user and to CI.
    • Valid filter matching zero devices is a legitimate outcome. It is handled exactly like an empty device inventory: a console warning naming the filter and the before/after counts, D2D tests skipped, and API/Robot tests still run and determine the exit code. Consequence: in a mixed run this exits 0, matching pre-existing empty-inventory behaviour rather than inventing a new rule for this feature.
  4. Validation Split: Syntax at the CLI, Semantics at the Orchestrator:

    • Syntax validation stays in the Typer callback because it is cheap, context-free, runs before the data merge, and — importantly — applies regardless of which frameworks execute. Moving it into the orchestrator would mean a malformed filter is never checked at all on a Robot-only or --render-only run.
    • Field validity can only be judged against the resolved device inventory, so it necessarily lives in the pyATS orchestrator. The two checks cannot be collapsed into one place without one of them regressing; they are instead unified on exit code and error presentation.
  5. Inventory Resolution Precedes Task Assembly:

    • Device inventory resolution and filter diagnostics run before any task coroutine is created. Aborting after the API coroutine had been appended to the task list left it un-awaited, silently cancelling the entire API suite with no signal beyond a RuntimeWarning. The ordering is now an invariant covered by a dedicated regression test.
  6. No In-Test Filter API (helpers removed before merge):

    • Earlier revisions exposed NACTestBase.filter_devices(), get_device_filters() and a device_filters property so an API test could re-apply the active filter to devices fetched from a controller. These were removed.
    • The filter's namespace is three canonical aliases (hostname, ip, os) overlaid on the raw data model device dict, so users may filter on any dotted path in their model. Controller payloads use a different schema, so the helper would require a controller-field-to-data-model-field translation for a set of names that is open-ended and chosen by the user at runtime. Passing controller dicts straight through yields either a DeviceFilterError or a silently empty list — the signature promised what the semantics could not deliver. The helpers had no callers and no tests.
    • Exposing the parsed filter spec instead was also rejected: authors would reimplement the matching rules (missing-field asymmetry, boolean lowercasing, null-as-absent, string coercion, re.search vs fullmatch) and produce API tests that silently disagree with D2D.
    • The underlying requirement is real and tracked in Allow API tests to scope to the same device set as D2D tests #956: scope an API test to the device set D2D already resolved, by intersecting on a stable key such as hostname, rather than re-evaluating the filter against a foreign schema.
  7. Temporary Lock-Step Dependency:

    • pyproject.toml temporarily references nac-test-pyats-common on branch feat/60-device-filter via [tool.uv.sources] so that E2E test suites run against the updated resolver before package release.

Testing Done

  • Unit tests added/updated:
    • AST filter engine parser & matcher unit tests (tests/unit/utils/test_device_filter.py)
    • Orchestrator diagnostics and warnings unit tests (tests/unit/pyats_core/test_orchestrator_device_filter.py)
  • Integration tests performed:
    • CLI syntax validation and warnings (tests/integration/test_cli_device_filter.py)
  • E2E tests executed:
    • PYATS_D2D_ONLY_SCENARIO and DEVICE_FILTER_TAG_SCENARIO verifying filtering on real testbed generation and HTML report output
  • Documentation added:
    • Added dedicated --device-filter section to README.md explaining canonical attributes, operators, case sensitivity, and syntax examples.
  • All existing tests pass (pytest / pre-commit run -a)

Test Commands Used

.venv/bin/pytest -n auto --dist loadscope tests/unit/utils/test_device_filter.py tests/unit/pyats_core/test_orchestrator_device_filter.py tests/integration/test_cli_device_filter.py tests/e2e/test_e2e_scenarios.py -k "device_filter or d2d"
.venv/bin/pre-commit run --all-files

Checklist

  • Code follows project style guidelines (pre-commit run -a passes)
  • Self-review of code completed
  • Code is commented where necessary
  • No new warnings introduced
  • Changes work on both macOS and Linux

Additional Notes

  • Requires nac-test-pyats-common with support for BaseDeviceResolver filtering (netascode/nac-test-pyats-common#61, PR to be merged).
  • Prior to FCS, the temporary uv.sources git pointer will be replaced with nac-test-pyats-common>=0.4.0b3.
  • Merge-order hazard: this PR pins nac-test-pyats-common to branch feat/60-device-filter, and that PR's CI in turn installs nac-test from branch feat/932-device-filter-d2d. The pins are symmetric, so whichever merges first will redden the other until its ref is retargeted. The second PR needs a retarget commit before it can go green. Both pins are tagged TEMPORARY - REMOVE BEFORE RELEASE.
  • Follow-up tracked in Allow API tests to scope to the same device set as D2D tests #956 (scoping API tests to the D2D device set).

Add --device-filter CLI option to filter devices during Direct-to-Device (D2D) testing:

- Implement device filter expression parser supporting equality (=), regex (=~),
  negation (!=), and nested paths in nac_test.utils.device_filter
- Add --device-filter argument to Typer CLI with custom syntax validator
- Serialize filters via NAC_TEST_DEVICE_FILTER_JSON environment variable
- Support filtering in NACTestBase via filter_devices() and device_filters property
- Forward filter diagnostics from pyATS resolver to orchestrator and log summary warnings
- Warn when --device-filter is supplied without active D2D/API pyATS tests
- Add comprehensive unit, integration, and E2E test coverage
- Add temporary git dependency for nac-test-pyats-common branch feat/60-device-filter
…#932)

PR review follow-ups on the --device-filter implementation.

Documentation corrections — the README described an API that did not exist:
- Operator table listed `==` and `!~`. Neither works: `!~` raises "missing
  operator", and `role==spine` parses as value "=spine", silently matching
  nothing. Table now lists the four real operators (=, !=, =~, !=~).
- Env var was documented as "comma or whitespace separated". Click splits
  multiple-value env vars on whitespace only, so a comma-separated value
  parses as a single filter and silently matches nothing.
- Claims of int/float/bool/None coercion and case-insensitive boolean
  literals were unimplemented. Replaced with a String Comparison bullet
  describing the actual semantics, plus a Missing Fields bullet documenting
  the positive/negative operator asymmetry.

Behaviour changes:
- Drop the "Available fields" listing from the unknown-field error, along
  with extract_available_keys() and FilterResult.keys_seen. Key discovery
  only walked two levels while _resolve_path traverses arbitrary depth, so
  the hint was misleading on nested data models.
- filter_devices() no longer fails open. Previously, if every filter
  referenced an unknown field it returned the full unfiltered device list,
  so a typo could widen the device set. Unknown fields now raise when
  strict=True and are warned-but-still-applied when strict=False.
- Parse the operator with a leftmost-longest regex derived from
  VALID_OPERATORS instead of a substring-anywhere scan, so values
  containing operator characters no longer steal the split point
  (e.g. 'hostname=a=~b' -> field 'hostname', value 'a=~b').
- Replace print() with typer.echo() for the filter summary line.

Tests:
- e2e credential injection is scoped to requires_testbed scenarios again,
  restoring isolation for non-D2D scenarios, while still covering every
  mock device so filtered-out devices resolve during testbed generation.
- test_excluded_device_absent_from_reports asserted against a summary.html
  that is never produced, guarded by `if exists()` so it always passed
  vacuously. Now asserts on combined_summary.html and xunit.xml.
- Add parse regression cases for operator characters inside values.

Noted for follow-up: TODO(#932) on the exit-code semantics of the two
early returns (both yield an empty PyATSResults, so "filter matched no
devices" is indistinguishable from "nothing to run"), and on the shape of
the filter_devices/get_device_filters public API, which has no in-tree
callers yet.
--device-filter had two failure modes collapsed into one outcome: both an
unknown filter field and a valid filter matching no devices returned an
empty PyATSResults(), making a typo indistinguishable from a clean run.

Separate them:

- Unknown field (absent from every device) is a filter definition error.
  The user's intent is unknowable, so raise DeviceFilterError and abort
  before anything executes. The CLI maps it to EXIT_INVALID_ARGS (2), the
  same code as a malformed expression caught by the --device-filter
  callback, so both filter failures report identically.

- Zero matches is a legitimate outcome. Warn on the console and skip D2D,
  exactly as for an empty inventory, letting API/Robot tests run and
  determine the exit code.

Device inventory resolution now precedes task assembly. Aborting after the
API coroutine was created left it un-awaited, silently cancelling the API
suite; the unknown-field test asserts this ordering.

run_tests() re-raises DeviceFilterError ahead of the generic handler, which
would otherwise bury it in an api-slot error result (exit 255).

Documents both behaviours in the README, including the previously
undocumented no-matching-devices case.
base_test exposed filter_devices(), get_device_filters() and a
device_filters property so test authors could apply the active
--device-filter to devices retrieved from a controller API. Remove them:
the API cannot be made to work as intended.

The filter's namespace is three canonical aliases (hostname, ip, os)
overlaid on the raw data model device dict, so a user may filter on any
dotted path in their model -- bgp.asn, site, tags, and so on. Controller
payloads use a different schema. For the helper to be useful, a test author
would have to translate controller fields into data model field names for a
set of names that is open-ended and chosen by the user at runtime. Passing
controller dicts through unmodified instead yields either a DeviceFilterError
or a silently empty device list, so the signature promised something the
semantics could not deliver.

Nothing is lost: the helpers had no callers and no tests. Exposing the
parsed filter spec instead was considered and rejected, as authors would
then reimplement the matching rules (missing fields versus negative
operators, boolean lowercase normalisation, null-as-absent, string coercion,
re.search semantics) and produce API tests that silently disagree with D2D.

The underlying requirement is real and tracked separately: scope an API test
to the device set D2D resolved, by intersecting on a stable key such as
hostname, rather than by re-evaluating the filter against a foreign schema.

Document the filter's scope in the README, which previously left it implicit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] --device-filter: flexible device population filtering for PyATS D2D tests

1 participant