From eaecebcd4673af3891f1cba147490604838c716b Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 23 May 2026 13:49:29 +0200 Subject: [PATCH 1/4] Accept new port-mappings stanza format alongside legacy inline rear_port NetBox v4.5.0 replaced the legacy ForeignKey rear_port + rear_port_position on FrontPortTemplate with a true M2M PortTemplateMapping table, and v4.5.8 added a top-level `port-mappings:` section to the device-type YAML export (netbox-community/netbox#21859). The library should accept both formats so existing files remain valid while new submissions can use the M2M format. - schema/components.json: drop `rear_port` from front-port `required`; keep it as a valid property and use `dependentRequired` so a stray `rear_port_position` without `rear_port` is still rejected. - schema/devicetype.json, schema/moduletype.json: add a top-level `port-mappings` array with the four fields NetBox emits, mirroring the PortTemplateMapping model. - tests/definitions_test.py: leave the existing inline-format cross-reference and uniqueness loop untouched; add a self-contained follow-up block that validates the new stanza (front_port / rear_port name resolution, no-mix-per-port rule, cross-format (rear_port, rear_port_position) uniqueness, and stanza-internal (front_port, front_port_position) uniqueness). A comment documents that we deliberately do not require every front-port to be mapped, matching NetBox's own FrontPortTemplate.clean() which only enforces the upper bound positions >= mappings.count(). Refs marcinpsk/Device-Type-Library-Import#78 --- schema/components.json | 6 ++- schema/devicetype.json | 14 +++++++ schema/moduletype.json | 14 +++++++ tests/definitions_test.py | 83 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/schema/components.json b/schema/components.json index 8fc95aef58..fadfc9b356 100644 --- a/schema/components.json +++ b/schema/components.json @@ -198,9 +198,11 @@ }, "required": [ "name", - "type", - "rear_port" + "type" ], + "dependentRequired": { + "rear_port_position": ["rear_port"] + }, "additionalProperties": false }, "rear-port": { diff --git a/schema/devicetype.json b/schema/devicetype.json index 9f8ef3ea1a..c99bcf29e8 100644 --- a/schema/devicetype.json +++ b/schema/devicetype.json @@ -91,6 +91,20 @@ "$ref": "urn:devicetype-library:components#/definitions/rear-port" } }, + "port-mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "front_port": { "type": "string", "maxLength": 64 }, + "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "rear_port": { "type": "string", "maxLength": 64 }, + "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } + }, + "required": ["front_port", "rear_port"], + "additionalProperties": false + } + }, "module-bays": { "type": "array", "items": { diff --git a/schema/moduletype.json b/schema/moduletype.json index cc0112ac94..ab126de57e 100644 --- a/schema/moduletype.json +++ b/schema/moduletype.json @@ -66,6 +66,20 @@ "$ref": "urn:devicetype-library:components#/definitions/rear-port" } }, + "port-mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "front_port": { "type": "string", "maxLength": 64 }, + "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "rear_port": { "type": "string", "maxLength": 64 }, + "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } + }, + "required": ["front_port", "rear_port"], + "additionalProperties": false + } + }, "module-bays": { "type": "array", "items": { diff --git a/tests/definitions_test.py b/tests/definitions_test.py index 3d9a07b26b..6ee2d77959 100644 --- a/tests/definitions_test.py +++ b/tests/definitions_test.py @@ -281,6 +281,89 @@ def test_definitions(file_path, schema, change_type): ) rear_port_positions[key] = fp.get("name") + # Validate the optional `port-mappings` stanza (NetBox v4.5+ format). + # The library accepts both formats: the legacy inline `rear_port` / + # `rear_port_position` on each front-port (checked in the loop above), + # and the new top-level `port-mappings:` list. A given front-port must + # use only one of the two formats. + # + # Note: we deliberately do NOT require every front-port to be referenced + # by some mapping (inline or stanza). NetBox v4.5+ permits unmapped + # front-ports — `FrontPortTemplate.clean()` enforces only the upper + # bound `positions >= mappings.count()` — so DTL matches that + # permissiveness rather than being stricter than the target system. + if any(x in file_path for x in ("device-types", "module-types")): + port_mappings = definition.get("port-mappings", []) or [] + front_port_names = { + fp.get("name") for fp in front_ports if isinstance(fp, dict) + } + front_ports_with_inline_rear = { + fp.get("name") for fp in front_ports + if isinstance(fp, dict) and fp.get("rear_port") + } + front_port_positions = {} + + for pm in port_mappings: + if not isinstance(pm, dict): + continue + + fp_ref = pm.get("front_port") + rp_ref = pm.get("rear_port") + + if fp_ref and fp_ref not in front_port_names: + pytest.fail( + f"{file_path}: port-mappings entry references " + f"front_port '{fp_ref}', but no such front-port exists. " + f"Defined front-ports: {sorted(front_port_names)}", + pytrace=False, + ) + + if rp_ref and rp_ref not in rear_port_names: + pytest.fail( + f"{file_path}: port-mappings entry references " + f"rear_port '{rp_ref}', but no such rear-port exists. " + f"Defined rear-ports: {sorted(rear_port_names)}", + pytrace=False, + ) + + # Reject mixing inline `rear_port` and a stanza entry for the + # same front-port — pick one format per port. + if fp_ref in front_ports_with_inline_rear: + pytest.fail( + f"{file_path}: front-port '{fp_ref}' has inline " + f"'rear_port' AND appears in 'port-mappings' stanza. " + f"Use only one format per front-port.", + pytrace=False, + ) + + # (rear_port, rear_port_position) uniqueness — checked across + # both formats by reusing `rear_port_positions` from above. + if rp_ref: + rear_port_pos = pm.get("rear_port_position", 1) + key = (rp_ref, rear_port_pos) + if key in rear_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry for front_port " + f"'{fp_ref}' has duplicate (rear_port, " + f"rear_port_position) = ('{rp_ref}', {rear_port_pos}). " + f"Already used by '{rear_port_positions[key]}'.", + pytrace=False, + ) + rear_port_positions[key] = f"port-mappings entry for '{fp_ref}'" + + # (front_port, front_port_position) uniqueness within the stanza. + if fp_ref: + front_port_pos = pm.get("front_port_position", 1) + fkey = (fp_ref, front_port_pos) + if fkey in front_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry has duplicate " + f"(front_port, front_port_position) = " + f"('{fp_ref}', {front_port_pos}).", + pytrace=False, + ) + front_port_positions[fkey] = True + # Verify the slug is valid, only if the definition type is a Device if this_device.isDevice: assert this_device.verify_slug(KNOWN_SLUGS), pytest.fail(this_device.failureMessage, False) From e4000718803693951c1206b21fcb68c8ddf1906d Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 23 May 2026 14:06:20 +0200 Subject: [PATCH 2/4] Reject empty front_port/rear_port in port-mappings via minLength: 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without minLength, an empty string passed maxLength: 64 at the schema layer, and the cross-reference checks in tests/definitions_test.py used truthiness guards (`if fp_ref and ...`) — so an empty value would silently bypass the front_port / rear_port resolution and uniqueness checks. Apply minLength: 1 to front_port and rear_port in the port-mappings stanza in both devicetype.json and moduletype.json. --- schema/devicetype.json | 4 ++-- schema/moduletype.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/schema/devicetype.json b/schema/devicetype.json index c99bcf29e8..9de3eedda6 100644 --- a/schema/devicetype.json +++ b/schema/devicetype.json @@ -96,9 +96,9 @@ "items": { "type": "object", "properties": { - "front_port": { "type": "string", "maxLength": 64 }, + "front_port": { "type": "string", "minLength": 1, "maxLength": 64 }, "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, - "rear_port": { "type": "string", "maxLength": 64 }, + "rear_port": { "type": "string", "minLength": 1, "maxLength": 64 }, "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } }, "required": ["front_port", "rear_port"], diff --git a/schema/moduletype.json b/schema/moduletype.json index ab126de57e..339677671b 100644 --- a/schema/moduletype.json +++ b/schema/moduletype.json @@ -71,9 +71,9 @@ "items": { "type": "object", "properties": { - "front_port": { "type": "string", "maxLength": 64 }, + "front_port": { "type": "string", "minLength": 1, "maxLength": 64 }, "front_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 }, - "rear_port": { "type": "string", "maxLength": 64 }, + "rear_port": { "type": "string", "minLength": 1, "maxLength": 64 }, "rear_port_position": { "type": "integer", "minimum": 1, "maximum": 1024 } }, "required": ["front_port", "rear_port"], From 85b964e897b97eb650958ed3253058eda2d6407c Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 23 May 2026 14:06:20 +0200 Subject: [PATCH 3/4] Trust schema for structural validation in port-mappings test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema (port-mappings items: type=object, required=[front_port, rear_port], minLength: 1 on both strings) is the source of truth for each entry's shape. The test block was wrapping cross-reference and uniqueness checks in `if fp_ref and ...` / `if rp_ref:` guards as a silent fallback for malformed entries — duplicating what the schema already enforces, and creating a drift risk if the schema ever changes. Remove the silent guards and the non-dict skip; access required keys directly via `pm["front_port"]` / `pm["rear_port"]`. Add a comment at the top of the loop documenting that structural validity is the schema's job, and this block only enforces the cross-document invariants the schema can't express. --- tests/definitions_test.py | 59 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/tests/definitions_test.py b/tests/definitions_test.py index 6ee2d77959..26ff2cd7de 100644 --- a/tests/definitions_test.py +++ b/tests/definitions_test.py @@ -303,14 +303,15 @@ def test_definitions(file_path, schema, change_type): } front_port_positions = {} + # Structural validity of each entry (object shape, required keys, + # non-empty strings) is enforced by the JSON schema at line 228 — + # don't re-check those invariants here. This block only enforces + # cross-document rules the schema can't express. for pm in port_mappings: - if not isinstance(pm, dict): - continue + fp_ref = pm["front_port"] + rp_ref = pm["rear_port"] - fp_ref = pm.get("front_port") - rp_ref = pm.get("rear_port") - - if fp_ref and fp_ref not in front_port_names: + if fp_ref not in front_port_names: pytest.fail( f"{file_path}: port-mappings entry references " f"front_port '{fp_ref}', but no such front-port exists. " @@ -318,7 +319,7 @@ def test_definitions(file_path, schema, change_type): pytrace=False, ) - if rp_ref and rp_ref not in rear_port_names: + if rp_ref not in rear_port_names: pytest.fail( f"{file_path}: port-mappings entry references " f"rear_port '{rp_ref}', but no such rear-port exists. " @@ -338,31 +339,29 @@ def test_definitions(file_path, schema, change_type): # (rear_port, rear_port_position) uniqueness — checked across # both formats by reusing `rear_port_positions` from above. - if rp_ref: - rear_port_pos = pm.get("rear_port_position", 1) - key = (rp_ref, rear_port_pos) - if key in rear_port_positions: - pytest.fail( - f"{file_path}: port-mappings entry for front_port " - f"'{fp_ref}' has duplicate (rear_port, " - f"rear_port_position) = ('{rp_ref}', {rear_port_pos}). " - f"Already used by '{rear_port_positions[key]}'.", - pytrace=False, - ) - rear_port_positions[key] = f"port-mappings entry for '{fp_ref}'" + rear_port_pos = pm.get("rear_port_position", 1) + key = (rp_ref, rear_port_pos) + if key in rear_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry for front_port " + f"'{fp_ref}' has duplicate (rear_port, " + f"rear_port_position) = ('{rp_ref}', {rear_port_pos}). " + f"Already used by '{rear_port_positions[key]}'.", + pytrace=False, + ) + rear_port_positions[key] = f"port-mappings entry for '{fp_ref}'" # (front_port, front_port_position) uniqueness within the stanza. - if fp_ref: - front_port_pos = pm.get("front_port_position", 1) - fkey = (fp_ref, front_port_pos) - if fkey in front_port_positions: - pytest.fail( - f"{file_path}: port-mappings entry has duplicate " - f"(front_port, front_port_position) = " - f"('{fp_ref}', {front_port_pos}).", - pytrace=False, - ) - front_port_positions[fkey] = True + front_port_pos = pm.get("front_port_position", 1) + fkey = (fp_ref, front_port_pos) + if fkey in front_port_positions: + pytest.fail( + f"{file_path}: port-mappings entry has duplicate " + f"(front_port, front_port_position) = " + f"('{fp_ref}', {front_port_pos}).", + pytrace=False, + ) + front_port_positions[fkey] = True # Verify the slug is valid, only if the definition type is a Device if this_device.isDevice: From 2d7b8b248ed257a71c67a00c55dbd3e73ed3f558 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 23 May 2026 14:20:12 +0200 Subject: [PATCH 4/4] Address review nits in port-mappings stanza validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `front_port_positions` to `stanza_front_port_positions` to make the variable's scope explicit (it only tracks the new stanza format) and remove any risk of confusion with `rear_port_positions` defined above. - Replace `dict[..., True]` membership tracking with a `set` — the dict was only ever used for `in` checks, never read. - Drop the hard-coded "line 228" reference from the schema-validation comment; refer to the schema validation conceptually so it doesn't drift as the file changes. --- tests/definitions_test.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/definitions_test.py b/tests/definitions_test.py index 26ff2cd7de..48734751bb 100644 --- a/tests/definitions_test.py +++ b/tests/definitions_test.py @@ -301,12 +301,13 @@ def test_definitions(file_path, schema, change_type): fp.get("name") for fp in front_ports if isinstance(fp, dict) and fp.get("rear_port") } - front_port_positions = {} + stanza_front_port_positions = set() # Structural validity of each entry (object shape, required keys, - # non-empty strings) is enforced by the JSON schema at line 228 — - # don't re-check those invariants here. This block only enforces - # cross-document rules the schema can't express. + # non-empty strings) is enforced by the JSON schema validated + # earlier in this test — don't re-check those invariants here. + # This block only enforces cross-document rules the schema can't + # express. for pm in port_mappings: fp_ref = pm["front_port"] rp_ref = pm["rear_port"] @@ -354,14 +355,14 @@ def test_definitions(file_path, schema, change_type): # (front_port, front_port_position) uniqueness within the stanza. front_port_pos = pm.get("front_port_position", 1) fkey = (fp_ref, front_port_pos) - if fkey in front_port_positions: + if fkey in stanza_front_port_positions: pytest.fail( f"{file_path}: port-mappings entry has duplicate " f"(front_port, front_port_position) = " f"('{fp_ref}', {front_port_pos}).", pytrace=False, ) - front_port_positions[fkey] = True + stanza_front_port_positions.add(fkey) # Verify the slug is valid, only if the definition type is a Device if this_device.isDevice: