diff --git a/.coverage b/.coverage index 1dd0da0..d5eedc9 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 64500cd..7f3504a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,7 +6,7 @@ jobs: tox: strategy: matrix: - python-version: [ '3.10', '3.11', '3.12' ] + python-version: [ '3.10', '3.11', '3.12', '3.13' ] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/.gitignore b/.gitignore index 45e7ab1..25f022b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ htmlcov/ docs/build/ # JSNAC default output file -jsnac.schema.json \ No newline at end of file +jsnac.schema.json + +# Release notes +release.txt \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d2f017a..cd65ff7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,7 +9,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.12" + python: "3.13" # You can also specify other tool versions: # nodejs: "19" # rust: "1.64" diff --git a/Dockerfile b/Dockerfile index f7da167..67fb71d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Will use this argument eventually to specify the python version so we can test against multiple versions -ARG PYTHON=3.11 +ARG PYTHON=3.13 FROM python:${PYTHON}-slim-bookworm ENV PATH="/root/.local/bin:$PATH" \ diff --git a/README.md b/README.md index 5876997..732a4f1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ interfaces: ipv4: "10.1.0.20/24" ``` -You can simply write out how you would like to document & validate this data in YAML using kinds, and this program will write out a JSON schema you can use. +You can simply write out how you would like to document & validate this data in a YAML file, and this program will generate a JSON schema you can use. ```yaml header: @@ -59,33 +59,33 @@ schema: type: "object" properties: hostname: - kind: { name: "string" } + js_kind: { name: "string" } model: - kind: { name: "string" } + js_kind: { name: "string" } device_type: - kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + js_kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } system: type: "object" properties: domain_name: - kind: { name: "string" } + js_kind: { name: "string" } ntp_servers: type: "array" items: - kind: { name: "ipv4" } + js_kind: { name: "ipv4" } interfaces: type: "array" items: type: "object" properties: if: - kind: { name: "string" } + js_kind: { name: "string" } desc: - kind: { name: "string" } + js_kind: { name: "string" } ipv4: - kind: { name: "ipv4_cidr" } + js_kind: { name: "ipv4_cidr" } ipv6: - kind: { name: "ipv6_cidr" } + js_kind: { name: "ipv6_cidr" } ``` ```bash @@ -112,7 +112,7 @@ Which language server you use is specific to your environment and editor that yo ## Detailed Example -We also have full support for writing your own titles, descriptions, kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: +We also have full support for writing your own titles, descriptions, js_kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: ```yaml header: @@ -124,7 +124,7 @@ header: - system - interfaces -kinds: +js_kinds: hostname: title: "Hostname" description: "Hostname of the device" @@ -142,15 +142,15 @@ schema: type: "object" properties: hostname: - kind: { name: "hostname" } + js_kind: { name: "hostname" } model: - kind: { name: "string" } + js_kind: { name: "string" } device_type: title: "Device Type" description: | Device Type options are: router, switch, firewall, load-balancer - kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + js_kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } required: [ "hostname", "model", "device_type" ] system: title: "System" @@ -161,13 +161,13 @@ schema: type: "object" properties: domain_name: - kind: { name: "string" } + js_kind: { name: "string" } ntp_servers: title: "NTP Servers" description: "List of NTP servers" type: "array" items: - kind: { name: "ipv4" } + js_kind: { name: "ipv4" } required: [ "domain_name", "ntp_servers" ] interfaces: title: "Device Interfaces" @@ -182,17 +182,17 @@ schema: type: "object" properties: if: - kind: { name: "string" } + js_kind: { name: "string" } desc: - kind: { name: "string" } + js_kind: { name: "string" } ipv4: - kind: { name: "ipv4_cidr" } + js_kind: { name: "ipv4_cidr" } ipv6: - kind: { name: "ipv6_cidr" } + js_kind: { name: "ipv6_cidr" } required: [ "if" ] ``` -A full list of kinds are available in the [documentation](https://jsnac.readthedocs.io/en/latest/) +A full list of js_kinds are available in the [documentation](https://jsnac.readthedocs.io/en/latest/) ## Usage @@ -215,14 +215,15 @@ jsnac -f data/example-jsnac.yml -v ### Library ```python """ -This example demonstrates how to use the jsnac library to build a JSON schema from a YAML file in a Python script. -Example yml file is available here: +This example demonstrates how to use the jsnac library to build a JSON schema +from a YAML file in a Python script. An example YAML file is available below: + """ -from jsnac.core.infer import SchemaInferer +from jsnac.core.build import SchemaBuilder def main(): # Create a SchemaInferer object - jsnac = SchemaInferer() + jsnac = SchemaBuilder() # Load the YAML data however you like into the SchemaInferer object with open('data/example-jsnac.yml', 'r') as file: diff --git a/data/example-jsnac.json b/data/example-jsnac.json index eafccbb..d7282b2 100644 --- a/data/example-jsnac.json +++ b/data/example-jsnac.json @@ -5,7 +5,7 @@ "title": "Example Schema", "description": "Ansible host vars for my networking device. Requires the below objects:\n- chassis\n- system\n- interfaces\n" }, - "kinds": { + "js_kinds": { "hostname": { "title": "Hostname", "description": "Hostname of the device", @@ -17,22 +17,22 @@ "chassis": { "title": "Chassis", "description": "Object containing Chassis information. Has the below properties: \nhostname [required]: hostname\nmodel [required]: string\ndevice_type [required]: choice (router, switch, firewall, load-balancer)\n", - "type": "object", "properties": { "hostname": { - "kind": { + "js_kind": { "name": "hostname" } }, "model": { - "kind": { + "description": "Model of the device", + "js_kind": { "name": "string" } }, "device_type": { "title": "Device Type", "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", - "kind": { + "js_kind": { "name": "choice", "choices": [ "router", @@ -52,19 +52,17 @@ "system": { "title": "System", "description": "Object containing System information. Has the below properties:\ndomain_name [required]: string\nntp_servers [required]: list of ipv4 addresses\n", - "type": "object", "properties": { "domain_name": { - "kind": { + "js_kind": { "name": "string" } }, "ntp_servers": { "title": "NTP Servers", "description": "List of NTP servers", - "type": "array", "items": { - "kind": { + "js_kind": { "name": "ipv4" } } @@ -78,27 +76,25 @@ "interfaces": { "title": "Device Interfaces", "description": "List of device interfaces. Each interface has the below properties:\nif [required]: string\ndesc: string\nipv4: ipv4_cidr\nipv6: ipv6_cidr\n", - "type": "array", "items": { - "type": "object", "properties": { "if": { - "kind": { + "js_kind": { "name": "string" } }, "desc": { - "kind": { + "js_kind": { "name": "string" } }, "ipv4": { - "kind": { + "js_kind": { "name": "ipv4_cidr" } }, "ipv6": { - "kind": { + "js_kind": { "name": "ipv6_cidr" } } diff --git a/data/example-jsnac.yml b/data/example-jsnac.yml index dd13cf0..f17715a 100644 --- a/data/example-jsnac.yml +++ b/data/example-jsnac.yml @@ -9,7 +9,7 @@ header: - system - interfaces -kinds: +js_kinds: hostname: title: "Hostname" description: "Hostname of the device" @@ -24,18 +24,18 @@ schema: hostname [required]: hostname model [required]: string device_type [required]: choice (router, switch, firewall, load-balancer) - type: "object" properties: hostname: - kind: { name: "hostname" } + js_kind: { name: "hostname" } model: - kind: { name: "string" } + description: "Model of the device" + js_kind: { name: "string" } device_type: title: "Device Type" description: | Device Type options are: router, switch, firewall, load-balancer - kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + js_kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } required: [ "hostname", "model", "device_type" ] system: title: "System" @@ -43,16 +43,14 @@ schema: Object containing System information. Has the below properties: domain_name [required]: string ntp_servers [required]: list of ipv4 addresses - type: "object" properties: domain_name: - kind: { name: "string" } + js_kind: { name: "string" } ntp_servers: title: "NTP Servers" description: "List of NTP servers" - type: "array" items: - kind: { name: "ipv4" } + js_kind: { name: "ipv4" } required: [ "domain_name", "ntp_servers" ] interfaces: title: "Device Interfaces" @@ -62,16 +60,14 @@ schema: desc: string ipv4: ipv4_cidr ipv6: ipv6_cidr - type: "array" items: - type: "object" properties: if: - kind: { name: "string" } + js_kind: { name: "string" } desc: - kind: { name: "string" } + js_kind: { name: "string" } ipv4: - kind: { name: "ipv4_cidr" } + js_kind: { name: "ipv4_cidr" } ipv6: - kind: { name: "ipv6_cidr" } + js_kind: { name: "ipv6_cidr" } required: [ "if" ] \ No newline at end of file diff --git a/data/example.schema.json b/data/example.schema.json index b341ba1..baeb06e 100644 --- a/data/example.schema.json +++ b/data/example.schema.json @@ -14,37 +14,88 @@ "type": "string", "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$", "title": "IPv6 Address", - "description": "Short IPv6 address (String) \n Accepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses" + "description": "Short IPv6 address (String) \nAccepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses" }, "ipv4_cidr": { "type": "string", "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", "title": "IPv4 CIDR", - "description": "IPv4 CIDR (String) \n Format: xxx.xxx.xxx.xxx/xx" + "description": "IPv4 CIDR (String) \nFormat: xxx.xxx.xxx.xxx/xx" }, "ipv6_cidr": { "type": "string", - "pattern": "(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", - "title": "IPv6 CIDR", - "description": "Full IPv6 CIDR (String) \n Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx" + "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", + "description": "Full IPv6 CIDR (String) \nFormat: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx" }, "ipv4_prefix": { "type": "string", - "title": "IPv4 Prefix", "pattern": "^/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", - "description": "IPv4 Prefix (String) \n Format: /xx between 0 and 32" + "description": "IPv4 Prefix (String) \nFormat: /xx between 0 and 32" }, "ipv6_prefix": { "type": "string", - "title": "IPv6 Prefix", "pattern": "^/(32|36|40|44|48|52|56|60|64|128)$", - "description": "IPv6 prefix (String) \n Format: /xx between 32 and 64 in increments of 4. also /128" + "description": "IPv6 prefix (String) \nFormat: /xx between 32 and 64 in increments of 4. also /128" }, "domain": { "type": "string", "pattern": "^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$", - "title": "Domain Name", - "description": "Domain name (String) \n Format: example.com" + "description": "Domain name (String) \nFormat: example.com" + }, + "email": { + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "description": "Email address (String) \nFormat: user@domain.com" + }, + "http_url": { + "type": "string", + "pattern": "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})([/\\w .-]*)*\\??([^#\\s]*)?(#.*)?$", + "description": "HTTP(s) URL (String) \nFormat: http://example.com" + }, + "uint16": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "16-bit Unsigned Integer \nRange: 0 to 65535" + }, + "uint32": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "32-bit Unsigned Integer \nRange: 0 to 4294967295" + }, + "uint64": { + "type": "integer", + "minimum": 0, + "maximum": 18446744073709551615, + "description": "64-bit Unsigned Integer \nRange: 0 to 18446744073709551615" + }, + "mtu": { + "type": "integer", + "minimum": 68, + "maximum": 9192, + "description": "Maximum Transmission Unit (MTU) \nRange: 68 to 9192" + }, + "mac": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", + "description": "MAC Address (String) \nFormat: xx:xx:xx:xx:xx:xx" + }, + "mac_dot": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{4}[.]){2}([0-9A-Fa-f]{4})$", + "description": "MAC Address with dots (String) \nFormat: xxxx.xxxx.xxxx" + }, + "vlan": { + "type": "integer", + "minimum": 1, + "maximum": 4094, + "description": "VLAN ID (Integer) \nRange: 1 to 4094" + }, + "docker_image": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "description": "Docker Image Name (String) \nFormat: alpine:latest" }, "hostname": { "title": "Hostname", @@ -57,21 +108,22 @@ "additionalProperties": false, "properties": { "chassis": { + "type": "object", + "additionalProperties": false, "title": "Chassis", "description": "Object containing Chassis information. Has the below properties: \nhostname [required]: hostname\nmodel [required]: string\ndevice_type [required]: choice (router, switch, firewall, load-balancer)\n", - "type": "object", "properties": { "hostname": { + "title": "hostname", "$ref": "#/$defs/hostname" }, "model": { - "type": "string", "title": "model", + "type": "string", "description": "String" }, "device_type": { - "title": "Device Type", - "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", + "title": "device_type", "enum": [ "router", "switch", @@ -87,20 +139,22 @@ ] }, "system": { + "type": "object", + "additionalProperties": false, "title": "System", "description": "Object containing System information. Has the below properties:\ndomain_name [required]: string\nntp_servers [required]: list of ipv4 addresses\n", - "type": "object", "properties": { "domain_name": { - "type": "string", "title": "domain_name", + "type": "string", "description": "String" }, "ntp_servers": { + "type": "array", "title": "NTP Servers", "description": "List of NTP servers", - "type": "array", "items": { + "title": "items", "$ref": "#/$defs/ipv4" } } @@ -111,26 +165,29 @@ ] }, "interfaces": { + "type": "array", "title": "Device Interfaces", "description": "List of device interfaces. Each interface has the below properties:\nif [required]: string\ndesc: string\nipv4: ipv4_cidr\nipv6: ipv6_cidr\n", - "type": "array", "items": { "type": "object", + "additionalProperties": false, "properties": { "if": { - "type": "string", "title": "if", + "type": "string", "description": "String" }, "desc": { - "type": "string", "title": "desc", + "type": "string", "description": "String" }, "ipv4": { + "title": "ipv4", "$ref": "#/$defs/ipv4_cidr" }, "ipv6": { + "title": "ipv6", "$ref": "#/$defs/ipv6_cidr" } }, diff --git a/data/regenerate_test_data.py b/data/regenerate_test_data.py index 36a2cb9..a794f22 100755 --- a/data/regenerate_test_data.py +++ b/data/regenerate_test_data.py @@ -25,7 +25,7 @@ import yaml -from jsnac.core.infer import SchemaInferer +from jsnac.core.build import SchemaBuilder def write_json(file: str) -> None: # noqa: D103 @@ -55,7 +55,7 @@ def main() -> None: # noqa: D103 write_json(example_jsnac_file) # Generate a schema for example-jsnac.yml with example_jsnac_file.open() as f: - jsnac = SchemaInferer() + jsnac = SchemaBuilder() jsnac.add_yaml(f.read()) schema = jsnac.build_schema() f.close() diff --git a/dist/jsnac-0.2.1-py3-none-any.whl b/dist/jsnac-0.2.1-py3-none-any.whl new file mode 100644 index 0000000..335a356 Binary files /dev/null and b/dist/jsnac-0.2.1-py3-none-any.whl differ diff --git a/dist/jsnac-0.2.1.tar.gz b/dist/jsnac-0.2.1.tar.gz new file mode 100644 index 0000000..2435dbb Binary files /dev/null and b/dist/jsnac-0.2.1.tar.gz differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 2dc3187..1bb3af4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,7 +9,7 @@ project = "JSNAC" copyright = "2024, Andrew Jones" author = "Andrew Jones" -release = "0.2.0" +release = "0.2.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 32cc3e3..dd70432 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -27,14 +27,15 @@ Library usage: .. code-block:: python """ - This example demonstrates how to use the jsnac library to build a JSON schema from a YAML file in a Python script. - Example yml file is available here: + This example demonstrates how to use the jsnac library to build a JSON schema + from a YAML file in a Python script. An example YAML file is available below: + """ - from jsnac.core.infer import SchemaInferer + from jsnac.core.build import SchemaBuilder def main(): # Create a SchemaInferer object - jsnac = SchemaInferer() + jsnac = SchemaBuilder() # Load the YAML data however you like into the SchemaInferer object with open('data/example-jsnac.yml', 'r') as file: diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 282ae35..5b44b1a 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -21,7 +21,7 @@ Take a basic Ansible host_vars YAML file for a host below: chassis: hostname: "ceos-spine1" model: "ceos" - type: "router" + device_type: "router" system: domain_name: "example.com" @@ -46,38 +46,33 @@ You can simply write out how you would like to validate this data, and this prog schema: chassis: title: "Chassis" - type: "object" properties: hostname: - kind: { name: "string" } + js_kind: { name: "string" } model: - kind: { name: "string" } + js_kind: { name: "string" } device_type: - kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + js_kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } system: - type: "object" properties: domain_name: - kind: { name: "string" } + js_kind: { name: "string" } ntp_servers: - type: "array" items: - kind: { name: "ipv4" } + js_kind: { name: "ipv4" } interfaces: - type: "array" items: - type: "object" properties: if: - kind: { name: "string" } + js_kind: { name: "string" } desc: - kind: { name: "string" } + js_kind: { name: "string" } ipv4: - kind: { name: "ipv4_cidr" } + js_kind: { name: "ipv4_cidr" } ipv6: - kind: { name: "ipv6_cidr" } + js_kind: { name: "ipv6_cidr" } -We also have full support for writing your own titles, descriptions, kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: +We also have full support for writing your own titles, descriptions, js_kinds (sub-schemas), objects that are required, etc. A more fleshed out example of the same schema is below: .. code-block:: yaml @@ -90,7 +85,7 @@ We also have full support for writing your own titles, descriptions, kinds (sub- - system - interfaces - kinds: + js_kinds: hostname: title: "Hostname" description: "Hostname of the device" @@ -105,18 +100,17 @@ We also have full support for writing your own titles, descriptions, kinds (sub- hostname [required]: hostname model [required]: string device_type [required]: choice (router, switch, firewall, load-balancer) - type: "object" properties: hostname: - kind: { name: "hostname" } + js_kind: { name: "hostname" } model: - kind: { name: "string" } + js_kind: { name: "string" } device_type: title: "Device Type" description: | Device Type options are: router, switch, firewall, load-balancer - kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + js_kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } required: [ "hostname", "model", "device_type" ] system: title: "System" @@ -124,16 +118,14 @@ We also have full support for writing your own titles, descriptions, kinds (sub- Object containing System information. Has the below properties: domain_name [required]: string ntp_servers [required]: list of ipv4 addresses - type: "object" properties: domain_name: - kind: { name: "string" } + js_kind: { name: "string" } ntp_servers: title: "NTP Servers" description: "List of NTP servers" - type: "array" items: - kind: { name: "ipv4" } + js_kind: { name: "ipv4" } required: [ "domain_name", "ntp_servers" ] interfaces: title: "Device Interfaces" @@ -143,18 +135,16 @@ We also have full support for writing your own titles, descriptions, kinds (sub- desc: string ipv4: ipv4_cidr ipv6: ipv6_cidr - type: "array" items: - type: "object" properties: if: - kind: { name: "string" } + js_kind: { name: "string" } desc: - kind: { name: "string" } + js_kind: { name: "string" } ipv4: - kind: { name: "ipv4_cidr" } + js_kind: { name: "ipv4_cidr" } ipv6: - kind: { name: "ipv6_cidr" } + js_kind: { name: "ipv6_cidr" } required: [ "if" ] Motivation diff --git a/docs/source/jsnac.core.rst b/docs/source/jsnac.core.rst index 425e792..ba3c835 100644 --- a/docs/source/jsnac.core.rst +++ b/docs/source/jsnac.core.rst @@ -1,10 +1,10 @@ jsnac.core package ================== -jsnac.core.infer module +jsnac.core.build module ----------------------- -.. automodule:: jsnac.core.infer +.. automodule:: jsnac.core.build :members: :undoc-members: :show-inheritance: diff --git a/docs/source/types.rst b/docs/source/types.rst index 74cd105..f2a6c16 100644 --- a/docs/source/types.rst +++ b/docs/source/types.rst @@ -3,36 +3,93 @@ JSNAC Kinds See the following sections for details on the included JSNAC kinds you can use in your YAML file(s). -kind: pattern -******************* +js_kind: choice +****************** -This type is used to validate a string against a regular expression pattern. -The pattern should be a valid regex pattern that will be used to validate the string. -If you are going to use this more than once, it is recommended to use the kinds section so you can reuse the pattern. +This type is used to validate a string against a list of choices. +The choices should be a list of strings that the string will be validated against. **Example** .. code-block:: yaml chassis: - hostname: - kind: { name: "pattern", pattern: "^[a-zA-Z0-9-]{1,63}$" } + type: + js_kind: { name: "choice", choices: ["router", "switch", "firewall"] } -kind: choice +js_kind: ipv4 ****************** -This type is used to validate a string against a list of choices. -The choices should be a list of strings that the string will be validated against. +This type is used to validate a string against an IPv4 address. +The string will be validated against the below IPv4 address regex pattern. + +.. code-block:: text + + ^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$ **Example** .. code-block:: yaml - chassis: - type: - kind: { name: "choice", choices: ["router", "switch", "firewall"] } + system: + ip_address: + js_kind: { name: "ipv4" } + +js_kind: ipv6 +****************** + +This type is used to validate a string against an IPv6 address. +The string will be validated against the below IPv6 address regex pattern. + +.. code-block:: text + + ^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$ + +**Example** + +.. code-block:: yaml + + system: + ip_address: + js_kind: { name: "ipv6" } + +js_kind: ipv4_cidr +****************** + +This type is used to validate a string against an IPv4 CIDR address. +The string will be validated against the below IPv4 CIDR address regex pattern. + +.. code-block:: text + + ^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$ + +**Example** -kind: domain +.. code-block:: yaml + + system: + ip_address: + js_kind: { name: "ipv4_cidr" } + +js_kind: ipv6_cidr +****************** + +This type is used to validate a string against an IPv6 CIDR address. +The string will be validated against the below IPv6 CIDR address regex pattern. + +.. code-block:: text + + ^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$ + +**Example** + +.. code-block:: yaml + + system: + ip_address: + js_kind: { name: "ipv6_cidr" } + +js_kind: domain ****************** This type is used to validate a string against a domain name. @@ -48,22 +105,159 @@ The string will be validated against the below domain name regex pattern. system: domain_name: - kind: { name: "domain" } + js_kind: { name: "domain" } -kind: ipv4 +js_kind: email ****************** -This type is used to validate a string against an IPv4 address. -The string will be validated against the below IPv4 address regex pattern. +This type is used to validate a string against an email address. +The string will be validated against the below email address regex pattern. .. code-block:: text - ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ + ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ **Example** .. code-block:: yaml system: - ip_address: - kind: { name: "ipv4" } \ No newline at end of file + email_address: + js_kind: { name: "email" } + +js_kind: http_url +****************** + +This type is used to validate a string against an HTTP URL. +The string will be validated against the below HTTP URL regex pattern. + +.. code-block:: text + + ^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})([/\\w .-]*)*\\??([^#\\s]*)?(#.*)?$ + +**Example** + +.. code-block:: yaml + + system: + sftp_server: + js_kind: { name: "http_url" } + +js_kind: uint16 +****************** + +This type is used to validate a string against a 16-bit unsigned integer (0 to 65535). + +**Example** + +.. code-block:: yaml + + bgp: + as_number: + js_kind: { name: "uint16" } + +js_kind: uint32 +****************** + +This type is used to validate a string against a 32-bit unsigned integer (0 to 4294967295). + +**Example** + +.. code-block:: yaml + + bgp: + as_number: + js_kind: { name: "uint32" } + +js_kind: uint64 +****************** + +This type is used to validate a string against a 64-bit unsigned integer (0 to 18446744073709551615). + +**Example** + +.. code-block:: yaml + + interface: + statistics: + in_octets: { name: "uint64" } + +js_kind: mtu +****************** + +This type is used to validate a string against a maximum transmission unit (MTU) value (68 to 9192). + +**Example** + +.. code-block:: yaml + + interface: + mtu: + js_kind: { name: "mtu" } + +js_kind: mac +****************** + +This type is used to validate a string against a MAC address (i.e ff:ff:ff:ff:ff:ff). +The string will be validated against the below MAC address regex pattern. + +.. code-block:: text + + ^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$ + +**Example** + +.. code-block:: yaml + + system: + mac_address: + js_kind: { name: "mac" } + +js_kind: mac_dot +****************** + +This type is used to validate a string against a MAC address with dot separator (i.e ffff.ffff.ffff). +The string will be validated against the below MAC address regex pattern. + +.. code-block:: text + + ^([0-9A-Fa-f]{4}[.]){2}([0-9A-Fa-f]{4})$ + +**Example** + +.. code-block:: yaml + + system: + mac_address: + js_kind: { name: "mac_dot" } + +js_kind: vlan +****************** + +This type is used to validate a string against a VLAN ID (1 to 4094). + +**Example** + +.. code-block:: yaml + + interface: + vlan_id: + js_kind: { name: "vlan" } + +js_kind: docker_image +********************* + +This type is used to validate a string against a Docker image name. +The string will be validated against the below Docker image name regex pattern. + +.. code-block:: text + + ^[a-z0-9]+(?:[._-][a-z0-9]+)*$ + +**Example** + +.. code-block:: yaml + + system: + docker_image: + js_kind: { name: "docker_image" } \ No newline at end of file diff --git a/jsnac/__init__.py b/jsnac/__init__.py index b855d7c..d4a03ac 100644 --- a/jsnac/__init__.py +++ b/jsnac/__init__.py @@ -1,6 +1,6 @@ -from .core.infer import SchemaInferer +from .core.build import SchemaBuilder -__version__ = "0.1.0" +__version__ = "0.2.1" __all__ = [ - "SchemaInferer", + "SchemaBuilder", ] diff --git a/jsnac/core/build.py b/jsnac/core/build.py new file mode 100755 index 0000000..8ee8ebd --- /dev/null +++ b/jsnac/core/build.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 + +import json +import logging +from typing import Any, ClassVar + +import yaml + + +class SchemaBuilder: + """ + SchemaBuilder is a class designed to build JSON schemas from JSON or YAML data. + It supports predefined types and allows for user-defined kinds. + + Variables: + user_defined_kinds (dict): A class variable to store user-defined kinds. + + Methods: + __init__(): + Initializes the instance of the class, setting up a logger. + _view_user_defined_kinds() -> dict: + Class method to view any user-defined kinds. + _add_user_defined_kinds(kinds: dict) -> None: + Class method to add a user-defined kind. + add_json(json_data: str) -> None: + Parses the provided JSON data and stores it in the instance. + add_yaml(yaml_data: str) -> None: + Parses the provided YAML data, converts it to JSON format, and stores it in the instance. + build_schema() -> str: + The main function of this class, returns a JSON schema based on the data added to the schema builder. + _build_definitions(data: dict) -> dict: + Builds a dictionary of definitions based on predefined types and any additional js_kinds provided. + _build_properties(title: str, data: dict) -> dict: + Builds properties for the schema based on the provided data. + _build_kinds(title: str, data: dict) -> dict: + Builds js_kinds for the schema based on the provided data. + + """ + + user_defined_kinds: ClassVar[dict] = {} + + def __init__(self) -> None: + """ + Initializes the instance of the class. + + This constructor sets up a logger for the class instance using the module's + name. It also adds a NullHandler to the logger to prevent any logging + errors if no other handlers are configured. + + Attributes: + log (logging.Logger): Logger instance for the class. + + """ + self.log = logging.getLogger(__name__) + self.log.addHandler(logging.NullHandler()) + + @classmethod + def _view_user_defined_kinds(cls) -> dict: + return cls.user_defined_kinds + + @classmethod + def _add_user_defined_kinds(cls, kinds: dict) -> None: + cls.user_defined_kinds.update(kinds) + + # Take in JSON data and confirm it is valid JSON + def add_json(self, json_data: str) -> None: + """ + Parses the provided JSON data, and stores it in the instance. + + Args: + json_data (str): A string containing JSON data. + + Raises: + ValueError: If the provided JSON data is invalid. + + """ + try: + load_json_data = json.loads(json_data) + self.log.debug("JSON content: \n%s", load_json_data) + self.data = load_json_data + except json.JSONDecodeError as e: + msg = "Invalid JSON data: %s", e + self.log.exception(msg) + raise ValueError(msg) from e + + def add_yaml(self, yaml_data: str) -> None: + """ + Parses the provided YAML data, converts it to JSON format, and stores it in the instance. + + Args: + yaml_data (str): A string containing YAML formatted data. + + Raises: + ValueError: If the provided YAML data is invalid. + + """ + try: + load_yaml_data = yaml.safe_load(yaml_data) + self.log.debug("YAML content: \n%s", load_yaml_data) + except yaml.YAMLError as e: + msg = "Invalid YAML data: %s", e + self.log.exception(msg) + raise ValueError(msg) from e + json_dump = json.dumps(load_yaml_data, indent=4) + json_data = json.loads(json_dump) + self.log.debug("JSON content: \n%s", json_dump) + self.data = json_data + + def build_schema(self) -> str: + """ + Builds a JSON schema based on the data added to the schema builder. + This method constructs a JSON schema using the data previously added via + `add_json` or `add_yaml` methods. It supports JSON Schema draft-07 by default, + but can be configured to use other drafts if needed. + + Returns: + str: A JSON string representing the constructed schema. + + Raises: + ValueError: If no data has been added to the schema inferer. + + Notes: + - The schema's metadata (e.g., $schema, title, $id, description) is derived + from the "header" section of the provided data. + - Additional sub-schemas (definitions) can be added via the "js_kinds" section + of the provided data. + - The schemas for individual and nested properties are constructed + based on the "schema" section of the provided data. + + """ + # Check if the data has been added + if not hasattr(self, "data"): + msg = "No data has been added to the schema builder. Use add_json or add_yaml to add data." + self.log.error(msg) + raise ValueError(msg) + data = self.data + + self.log.debug("Building schema for: \n%s ", data) + # Using draft-07 until vscode $dynamicRef support is added (https://github.com/microsoft/vscode/issues/155379) + # Feel free to replace this with http://json-schema.org/draft/2020-12/schema if not using vscode. + schema = { + "$schema": data.get("header", {}).get("schema", "http://json-schema.org/draft-07/schema#"), + "title": data.get("header", {}).get("title", "JSNAC created Schema"), + "$id": data.get("header", {}).get("id", "jsnac.schema.json"), + "description": data.get("header", {}).get("description", "https://github.com/commitconfirmed/jsnac"), + "$defs": self._build_definitions(data.get("js_kinds", {})), + "type": data.get("type", "object"), + "additionalProperties": data.get("additionalProperties", False), + "properties": self._build_properties("Default", data.get("schema", {})), + } + return json.dumps(schema, indent=4) + + def _build_definitions(self, data: dict) -> dict: + """ + Build a dictionary of definitions based on predefined types and additional js_kinds provided in the input data. + + Args: + data (dict): A dictionary containing additional js_kinds to be added to the definitions. + + Returns: + dict: A dictionary containing definitions for our predefined types such as 'ipv4', 'ipv6', etc. + Additional js_kinds from the input data are also included. + + Raises: + None + + """ + self.log.debug("Building definitions for: \n%s ", data) + definitions: dict[str, dict[str, Any]] = { + # JSNAC defined data types, may eventually move these to a separate file + "ipv4": { + "type": "string", + "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$", # noqa: E501 + "title": "IPv4 Address", + "description": "IPv4 address (String) \n Format: xxx.xxx.xxx.xxx", + }, + # Decided to just go simple for now, may add more complex validation in the future from + # https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses + "ipv6": { + "type": "string", + "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$", + "title": "IPv6 Address", + "description": "Short IPv6 address (String) \nAccepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses", # noqa: E501 + }, + "ipv4_cidr": { + "type": "string", + "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", # noqa: E501 + "title": "IPv4 CIDR", + "description": "IPv4 CIDR (String) \nFormat: xxx.xxx.xxx.xxx/xx", + }, + "ipv6_cidr": { + "type": "string", + "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", + "description": "Full IPv6 CIDR (String) \nFormat: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx", + }, + "ipv4_prefix": { + "type": "string", + "pattern": "^/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", + "description": "IPv4 Prefix (String) \nFormat: /xx between 0 and 32", + }, + "ipv6_prefix": { + "type": "string", + "pattern": "^/(32|36|40|44|48|52|56|60|64|128)$", + "description": "IPv6 prefix (String) \nFormat: /xx between 32 and 64 in increments of 4. also /128", + }, + "domain": { + "type": "string", + "pattern": "^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$", + "description": "Domain name (String) \nFormat: example.com", + }, + "email": { + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "description": "Email address (String) \nFormat: user@domain.com", + }, + "http_url": { + "type": "string", + "pattern": "^(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})([/\\w .-]*)*\\??([^#\\s]*)?(#.*)?$", + "description": "HTTP(s) URL (String) \nFormat: http://example.com", + }, + "uint16": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "16-bit Unsigned Integer \nRange: 0 to 65535", + }, + "uint32": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "32-bit Unsigned Integer \nRange: 0 to 4294967295", + }, + "uint64": { + "type": "integer", + "minimum": 0, + "maximum": 18446744073709551615, + "description": "64-bit Unsigned Integer \nRange: 0 to 18446744073709551615", + }, + "mtu": { + "type": "integer", + "minimum": 68, + "maximum": 9192, + "description": "Maximum Transmission Unit (MTU) \nRange: 68 to 9192", + }, + "mac": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", + "description": "MAC Address (String) \nFormat: xx:xx:xx:xx:xx:xx", + }, + "mac_dot": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{4}[.]){2}([0-9A-Fa-f]{4})$", + "description": "MAC Address with dots (String) \nFormat: xxxx.xxxx.xxxx", + }, + "vlan": { + "type": "integer", + "minimum": 1, + "maximum": 4094, + "description": "VLAN ID (Integer) \nRange: 1 to 4094", + }, + "docker_image": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "description": "Docker Image Name (String) \nFormat: alpine:latest", + }, + } + # Check passed data for additional js_kinds and add them to the definitions + for kind, kind_data in data.items(): + self.log.debug("Building custom js_kind (%s): \n%s ", kind, kind_data) + definitions[kind] = {} + definitions[kind]["title"] = kind_data.get("title", f"{kind}") + definitions[kind]["description"] = kind_data.get("description", f"Custom Kind: {kind}") + # Only support a custom kind of pattern for now, will add more in the future + match kind_data.get("type"): + case "pattern": + definitions[kind]["type"] = "string" + if "regex" in kind_data: + definitions[kind]["pattern"] = kind_data["regex"] + self._add_user_defined_kinds({kind: True}) + else: + self.log.error("regex key is required for js_kind (%s) with type pattern", kind) + definitions[kind]["type"] = "null" + definitions[kind]["title"] = "Error" + definitions[kind]["description"] = "No regex key provided" + case _: + self.log.error( + "Invalid type (%s) for js_kind (%s), defaulting to string", kind_data.get("type"), kind + ) + definitions[kind]["type"] = "string" + definitions[kind]["title"] = "Error" + definitions[kind]["description"] = f"Invalid type ({kind_data.get('type')}), defaulted to string" + self.log.debug("Returned Definitions: \n%s ", data) + return definitions + + def _build_properties(self, title: str, data: dict) -> dict: + """ + Recursively builds properties for a given title and data dictionary. + + Args: + title (str): The title for the properties being built. + data (dict): The data dictionary containing properties to be processed. + + Returns: + dict: A dictionary representing the built properties. + + The function processes the input data dictionary to build properties based on its structure. + If the data contains a "js_kind" key, it delegates to the _build_kinds method. + If the data contains nested dictionaries or lists, it recursively processes them. + We also type to "object" or "array" based on the presence of "properties" or "items" keys. + + """ + self.log.debug("Building properties for: \n%s ", data) + # Check if there is a nested dictionary, if so we will check all keys and valid and then depending + # on the key we will recursively call this function again or build our js_kinds + if isinstance(data, dict): + if "js_kind" in data: + return self._build_kinds(title, data["js_kind"]) + # Else + properties = {} + # Add the type depending if our YAML has a properties or items key + if "properties" in data: + properties["type"] = "object" + # Check for additional properties, otherwise set to false + properties["additionalProperties"] = data.get("additional_properties", False) + if "items" in data: + properties["type"] = "array" + properties.update({k: self._build_properties(k, v) for k, v in data.items()}) + return properties + if isinstance(data, list): + return [self._build_properties(title, item) for item in data] + return data + + def _build_kinds(self, title: str, data: dict) -> dict: # noqa: PLR0912 + """ + Builds js_kinds for a given title and data dictionary. + + Args: + title (str): The title for the js_kinds being built. + data (dict): The data dictionary containing js_kinds to be processed. + + Returns: + dict: A dictionary representing the built js_kinds. + + """ + self.log.debug("Building js_kinds for Object (%s): \n%s ", title, data) + kind: dict = {} + # Add the title passed in from the parent object + kind["title"] = title + valid_js_kinds = [ + "ipv4", + "ipv6", + "ipv4_cidr", + "ipv6_cidr", + "ipv4_prefix", + "ipv6_prefix", + "domain", + "email", + "http_url", + "uint16", + "uint32", + "uint64", + "mtu", + "mac", + "mac_dot", + "vlan", + "docker_image", + ] + # Check if the kind is a valid predefined kind + if data.get("name") in valid_js_kinds: + kind["$ref"] = "#/$defs/{}".format(data["name"]) + # If not, check the kind type and build the schema based on some extra custom logic + else: + match data.get("name"): + # For the choice kind, read the choices object + case "choice": + if "choices" in data: + kind["enum"] = data["choices"] + kind.get("description", f"Choice of the below:\n{data['choices']}") + else: + self.log.error("Choice js_kind requires a choices object") + kind["description"] = "Choice js_kind requires a choices object" + kind["type"] = "null" + # Default types + case "string": + kind["type"] = "string" + kind["description"] = kind.get("description", "String") + case "number": + kind["type"] = "number" + kind["description"] = kind.get("description", "Integer or Float") + case "integer": + kind["type"] = "integer" + kind["description"] = kind.get("description", "Integer") + case "boolean": + kind["type"] = "boolean" + kind["description"] = kind.get("description", "Boolean") + case "null": + kind["type"] = "null" + kind["description"] = kind.get("description", "Null") + case _: + # Check if the kind is user-defined from the user_defined_kinds class variable + if data.get("name") in self._view_user_defined_kinds(): + kind["$ref"] = "#/$defs/{}".format(data["name"]) + else: + self.log.error("Invalid js_kind (%s) detected, defaulting to Null", data) + kind["description"] = f"Invalid js_kind ({data}), defaulting to Null" + kind["type"] = "null" + return kind diff --git a/jsnac/core/infer.py b/jsnac/core/infer.py deleted file mode 100755 index 7b74484..0000000 --- a/jsnac/core/infer.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 - -import json -import logging -from typing import ClassVar - -import yaml - - -class SchemaInferer: - """ - SchemaInferer is a class that infers JSON schemas from provided JSON or YAML data. - - user_defined_kinds (dict): A class variable that stores user-defined kinds. - - Methods: - __init__(): - Initializes the instance of the class, setting up a logger. - - _view_user_defined_kinds() -> dict: - Returns the user-defined kinds currently stored in the class variable. - - _add_user_defined_kinds(kinds: dict) -> None: - Adds user-defined kinds to the class variable. - - add_json(json_data: str) -> None: - Parses the provided JSON data and stores it in the instance. - - add_yaml(yaml_data: str) -> None: - Parses the provided YAML data, converts it to JSON format, and stores it in the instance. - - build_schema() -> str: - Builds a JSON schema based on the data added to the schema inferer. Returns the constructed schema. - - _build_definitions(data: dict) -> dict: - Builds the definitions section of the JSON schema. - - _build_properties(data: dict) -> dict: - Builds the properties section of the JSON schema. - - _build_property(obj: str, obj_data: dict) -> dict: - Builds a property for the JSON schema. - - _build_property_type(obj: str, obj_data: dict) -> dict: - Builds the type for a property in the JSON schema. - - _build_array_items(obj: str, obj_data: dict) -> dict: - Builds the items for an array property in the JSON schema. - - _build_kinds(obj: str, data: dict) -> dict: - Builds the kinds for a property in the JSON schema. - - """ - - user_defined_kinds: ClassVar[dict] = {} - - def __init__(self) -> None: - """ - Initializes the instance of the class. - - This constructor sets up a logger for the class instance using the module's - name. It also adds a NullHandler to the logger to prevent any logging - errors if no other handlers are configured. - - Attributes: - log (logging.Logger): Logger instance for the class. - - """ - self.log = logging.getLogger(__name__) - self.log.addHandler(logging.NullHandler()) - - @classmethod - def _view_user_defined_kinds(cls) -> dict: - return cls.user_defined_kinds - - @classmethod - def _add_user_defined_kinds(cls, kinds: dict) -> None: - cls.user_defined_kinds.update(kinds) - - # Take in JSON data and confirm it is valid JSON - def add_json(self, json_data: str) -> None: - """ - Parses the provided JSON data, and stores it in the instance. - - Args: - json_data (str): A string containing JSON data. - - Raises: - ValueError: If the provided JSON data is invalid. - - """ - try: - load_json_data = json.loads(json_data) - self.log.debug("JSON content: \n%s", json.dumps(load_json_data, indent=4)) - self.data = load_json_data - except json.JSONDecodeError as e: - msg = "Invalid JSON data: %s", e - self.log.exception(msg) - raise ValueError(msg) from e - - def add_yaml(self, yaml_data: str) -> None: - """ - Parses the provided YAML data, converts it to JSON format, and stores it in the instance. - - Args: - yaml_data (str): A string containing YAML formatted data. - - Raises: - ValueError: If the provided YAML data is invalid. - - """ - try: - load_yaml_data = yaml.safe_load(yaml_data) - self.log.debug("YAML content: \n%s", load_yaml_data) - except yaml.YAMLError as e: - msg = "Invalid YAML data: %s", e - self.log.exception(msg) - raise ValueError(msg) from e - json_dump = json.dumps(load_yaml_data, indent=4) - json_data = json.loads(json_dump) - self.log.debug("JSON content: \n%s", json_dump) - self.data = json_data - - def build_schema(self) -> str: - """ - Builds a JSON schema based on the data added to the schema inferer. - This method constructs a JSON schema using the data previously added via - `add_json` or `add_yaml` methods. It supports JSON Schema draft-07 by default, - but can be configured to use other drafts if needed. - - Returns: - str: A JSON string representing the constructed schema. - - Raises: - ValueError: If no data has been added to the schema inferer. - - Notes: - - The schema's metadata (e.g., $schema, title, $id, description) is derived - from the "header" section of the provided data. - - Additional sub-schemas (definitions) can be added via the "kinds" section - of the provided data. - - The schemas for individual and nested properties are constructed - based on the "schema" section of the provided data. - - """ - # Check if the data has been added - if not hasattr(self, "data"): - msg = "No data has been added to the schema inferer. Use add_json or add_yaml to add data." - self.log.error(msg) - raise ValueError(msg) - data = self.data - - self.log.debug("Building schema for: \n%s ", json.dumps(data, indent=4)) - # Using draft-07 until vscode $dynamicRef support is added (https://github.com/microsoft/vscode/issues/155379) - # Feel free to replace this with http://json-schema.org/draft/2020-12/schema if not using vscode. - schema = { - "$schema": data.get("header", {}).get("schema", "http://json-schema.org/draft-07/schema#"), - "title": data.get("header", {}).get("title", "JSNAC created Schema"), - "$id": data.get("header", {}).get("id", "jsnac.schema.json"), - "description": data.get("header", {}).get("description", "https://github.com/commitconfirmed/jsnac"), - "$defs": self._build_definitions(data.get("kinds", {})), - "type": data.get("type", "object"), - "additionalProperties": data.get("additionalProperties", False), - "properties": self._build_properties(data.get("schema", {})), - } - return json.dumps(schema, indent=4) - - def _build_definitions(self, data: dict) -> dict: - """ - Build a dictionary of definitions based on predefined types and additional kinds provided in the input data. - - Args: - data (dict): A dictionary containing additional kinds to be added to the definitions. - - Returns: - dict: A dictionary containing definitions for our predefined types such as 'ipv4', 'ipv6', etc. - Additional kinds from the input data are also included. - - Raises: - None - - """ - self.log.debug("Building definitions for: \n%s ", json.dumps(data, indent=4)) - definitions = { - # JSNAC defined data types - "ipv4": { - "type": "string", - "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$", # noqa: E501 - "title": "IPv4 Address", - "description": "IPv4 address (String) \n Format: xxx.xxx.xxx.xxx", - }, - # Decided to just go simple for now, may add more complex validation in the future from - # https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses - "ipv6": { - "type": "string", - "pattern": "^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$", - "title": "IPv6 Address", - "description": "Short IPv6 address (String) \n Accepts both full and short form addresses, link-local addresses, and IPv4-mapped addresses", # noqa: E501 - }, - "ipv4_cidr": { - "type": "string", - "pattern": "^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", # noqa: E501 - "title": "IPv4 CIDR", - "description": "IPv4 CIDR (String) \n Format: xxx.xxx.xxx.xxx/xx", - }, - "ipv6_cidr": { - "type": "string", - "pattern": "(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)/(32|36|40|44|48|52|56|60|64|128)$", - "title": "IPv6 CIDR", - "description": "Full IPv6 CIDR (String) \n Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/xxx", - }, - "ipv4_prefix": { - "type": "string", - "title": "IPv4 Prefix", - "pattern": "^/(1[0-9]|[0-9]|2[0-9]|3[0-2])$", - "description": "IPv4 Prefix (String) \n Format: /xx between 0 and 32", - }, - "ipv6_prefix": { - "type": "string", - "title": "IPv6 Prefix", - "pattern": "^/(32|36|40|44|48|52|56|60|64|128)$", - "description": "IPv6 prefix (String) \n Format: /xx between 32 and 64 in increments of 4. also /128", - }, - "domain": { - "type": "string", - "pattern": "^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$", - "title": "Domain Name", - "description": "Domain name (String) \n Format: example.com", - }, - } - # Check passed data for additional kinds and add them to the definitions - for kind, kind_data in data.items(): - self.log.debug("Building custom kind (%s): \n%s ", kind, json.dumps(kind_data, indent=4)) - definitions[kind] = {} - definitions[kind]["title"] = kind_data.get("title", f"{kind}") - definitions[kind]["description"] = kind_data.get("description", f"Custom Kind: {kind}") - # Only support a custom kind of pattern for now, will add more in the future - match kind_data.get("type"): - case "pattern": - definitions[kind]["type"] = "string" - if "regex" in kind_data: - definitions[kind]["pattern"] = kind_data["regex"] - self._add_user_defined_kinds({kind: True}) - else: - self.log.error("regex key is required for kind (%s) with type pattern", kind) - definitions[kind]["type"] = "null" - definitions[kind]["title"] = "Error" - definitions[kind]["description"] = "No regex key provided" - case _: - self.log.error("Invalid type (%s) for kind (%s), defaulting to string", kind_data.get("type"), kind) - definitions[kind]["type"] = "string" - self.log.debug("Returned Definitions: \n%s ", json.dumps(definitions, indent=4)) - return definitions - - def _build_properties(self, data: dict) -> dict: - self.log.debug("Building properties for: \n%s ", json.dumps(data, indent=4)) - properties: dict = {} - stack = [(properties, data)] - - while stack: - current_properties, current_data = stack.pop() - for obj, obj_data in current_data.items(): - self.log.debug("Object: %s ", obj) - self.log.debug("Object Data: %s ", obj_data) - # Build the property for the object - current_properties[obj] = self._build_property(obj, obj_data) - # Check if there is a nested object or array type and add it to the stack - if "type" in obj_data and obj_data["type"] == "object" and "properties" in obj_data: - stack.append((current_properties[obj]["properties"], obj_data["properties"])) - elif "type" in obj_data and obj_data["type"] == "array" and "items" in obj_data: - item_data = obj_data["items"] - # Array is nested if it contains properties - if "properties" in item_data: - stack.append((current_properties[obj]["items"]["properties"], item_data["properties"])) - - self.log.debug("Returned Properties: \n%s ", json.dumps(properties, indent=4)) - return properties - - def _build_property(self, obj: str, obj_data: dict) -> dict: - self.log.debug("Building property for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) - property_dict: dict = {} - - if "title" in obj_data: - property_dict["title"] = obj_data["title"] - if "description" in obj_data: - property_dict["description"] = obj_data["description"] - if "type" in obj_data: - property_dict.update(self._build_property_type(obj, obj_data)) - elif "kind" in obj_data: - property_dict.update(self._build_kinds(obj, obj_data["kind"])) - - if "required" in obj_data: - property_dict["required"] = obj_data["required"] - - self.log.debug("Returned Property: \n%s ", json.dumps(property_dict, indent=4)) - return property_dict - - def _build_property_type(self, obj: str, obj_data: dict) -> dict: - self.log.debug("Building property type for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) - property_type = {"type": obj_data["type"]} - match obj_data["type"]: - case "object": - property_type["properties"] = {} - case "array": - property_type.update(self._build_array_items(obj, obj_data)) - case _: - self.log.error("Invalid type (%s), defaulting to Null", obj_data["type"]) - property_type["type"] = "null" - self.log.debug("Returned Property Type: \n%s ", json.dumps(property_type, indent=4)) - return property_type - - def _build_array_items(self, obj: str, obj_data: dict) -> dict: - self.log.debug("Building array items for Object (%s): \n%s ", obj, json.dumps(obj_data, indent=4)) - array_items = {} - if "items" in obj_data: - item_data = obj_data["items"] - if "type" in item_data: - array_items["items"] = {"type": item_data["type"]} - if "properties" in item_data: - array_items["items"]["properties"] = {} - if "required" in item_data: - array_items["items"]["required"] = item_data["required"] - elif "kind" in item_data: - array_items["items"] = self._build_kinds(obj, item_data["kind"]) - else: - self.log.error("Array items require a type or kind key") - array_items["items"] = {"type": "null"} - else: - self.log.error("Array type requires an items key") - array_items["items"] = {"type": "null"} - self.log.debug("Returned Array Items: \n%s ", json.dumps(array_items, indent=4)) - return array_items - - def _build_kinds(self, obj: str, data: dict) -> dict: # noqa: C901 PLR0912 - self.log.debug("Building kinds for Object (%s): \n%s ", obj, json.dumps(data, indent=4)) - kind: dict = {} - # Check if the kind has a type, if so we will continue to dig depper until kinds are found - # I should update this to be ruff compliant, but it makes sense to me at the moment - match data.get("name"): - # Kinds with regex patterns - case "ipv4": - kind["$ref"] = "#/$defs/ipv4" - case "ipv6": - kind["$ref"] = "#/$defs/ipv6" - case "ipv4_cidr": - kind["$ref"] = "#/$defs/ipv4_cidr" - case "ipv6_cidr": - kind["$ref"] = "#/$defs/ipv6_cidr" - case "ipv4_prefix": - kind["$ref"] = "#/$defs/ipv4_prefix" - case "ipv6_prefix": - kind["$ref"] = "#/$defs/ipv6_prefix" - case "domain": - kind["$ref"] = "#/$defs/domain" - # For the choice kind, read the choices object - case "choice": - if "choices" in data: - kind["enum"] = data["choices"] - else: - self.log.error("Choice kind requires a choices object") - kind["description"] = "Choice kind requires a choices object" - kind["type"] = "null" - # Default types - case "string": - kind["type"] = "string" - kind["title"] = obj - kind["description"] = "String" - case "number": - kind["type"] = "number" - kind["description"] = "Integer or Float" - case "boolean": - kind["type"] = "boolean" - kind["description"] = "Boolean" - case "null": - kind["type"] = "null" - kind["description"] = "Null" - case _: - # Check if the kind is a user defined kind - if data.get("name") in self._view_user_defined_kinds(): - kind["$ref"] = "#/$defs/{}".format(data["name"]) - else: - self.log.error("Invalid kind (%s), defaulting to Null", data) - kind["description"] = f"Invalid kind ({data}), defaulting to Null" - kind["type"] = "null" - return kind diff --git a/jsnac/utils/jsnac_cli.py b/jsnac/utils/jsnac_cli.py index 8dff9cb..a80a2ef 100755 --- a/jsnac/utils/jsnac_cli.py +++ b/jsnac/utils/jsnac_cli.py @@ -25,7 +25,7 @@ from argparse import ArgumentParser, Namespace from pathlib import Path -from jsnac import SchemaInferer, __version__ +from jsnac import SchemaBuilder, __version__ def _setup_logging() -> logging.Logger: @@ -107,7 +107,7 @@ def main(args: str | None = None) -> None: Main function for the JSNAC CLI. This function parses command-line arguments, sets up logging, and processes - an input file (either JSON or YAML) to infer a schema using the SchemaInferer + an input file (either JSON or YAML) to infer a schema using the SchemaBuilder class. The inferred schema is then written to an output file. Args: @@ -125,7 +125,7 @@ def main(args: str | None = None) -> None: # File is required but checking anyway if flags.file: input_file = Path(flags.file) - jsnac = SchemaInferer() + jsnac = SchemaBuilder() if flags.json: log.debug("Using JSON file: %s", flags.file) with input_file.open() as f: diff --git a/pyproject.toml b/pyproject.toml index 5f85ff1..5294420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "jsnac" -version = "0.2.0" +version = "0.2.1" description = "JSON Schema (for) Network as Code: Build JSON schemas from YAML" authors = ["Andrew Jones "] license = "MIT" @@ -14,6 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] [tool.poetry.dependencies] diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100755 index 0000000..207ab32 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +import json + +import pytest + +from jsnac.core.build import SchemaBuilder + + +# Test that bad JSON data raises an exception +def test_bad_json() -> None: + data = "bad ^^^ json data {}" + jsnac = SchemaBuilder() + with pytest.raises(ValueError, match="Invalid JSON data:"): + jsnac.add_json(data) + + +# Test that bad YAML data raises an exception +def test_bad_yaml() -> None: + data = "value: bad ^^^ yaml data --: \n +2" + jsnac = SchemaBuilder() + with pytest.raises(ValueError, match="Invalid YAML data:"): + jsnac.add_yaml(data) + + +# Test that no data raises an exception +def test_no_data() -> None: + jsnac = SchemaBuilder() + with pytest.raises(ValueError, match="No data has been added to the schema builder"): + jsnac.build_schema() + + +# Test that custom headers can be set +def test_custom_headers() -> None: + data = { + "header": { + "schema": "http://json-schema.org/draft/2020-12/schema", + "title": "Test Title", + "id": "test-schema.json", + "description": "Test Description", + } + } + jsnac = SchemaBuilder() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$schema"] == "http://json-schema.org/draft/2020-12/schema" + assert schema["title"] == "Test Title" + assert schema["$id"] == "test-schema.json" + assert schema["description"] == "Test Description" + + +# Test that default headers are set +def test_default_headers() -> None: + data = {"header": {}} + jsnac = SchemaBuilder() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$schema"] == "http://json-schema.org/draft-07/schema#" + assert schema["title"] == "JSNAC created Schema" + assert schema["$id"] == "jsnac.schema.json" + assert schema["description"] == "https://github.com/commitconfirmed/jsnac" + assert schema["type"] == "object" + assert schema["properties"] == {} + + +# Test that a custom js_kind of type pattern can be created +def test_custom_js_kind_pattern() -> None: + data = { + "js_kinds": { + "test": { + "title": "Test", + "description": "Test Description", + "type": "pattern", + "regex": "^[0-9]{3}$", + } + } + } + jsnac = SchemaBuilder() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$defs"]["test"]["pattern"] == "^[0-9]{3}$" + + +# Test that our custom js_kind of pattern fails when regex is not provided +def test_custom_js_kind_pattern_fail() -> None: + data = { + "js_kinds": { + "test": { + "title": "Test", + "description": "Test Description", + "type": "pattern", + "pattern": "wrong", + } + } + } + jsnac = SchemaBuilder() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$defs"]["test"]["type"] == "null" + assert schema["$defs"]["test"]["title"] == "Error" + assert schema["$defs"]["test"]["description"] == "No regex key provided" + + +# Test that an unknown js_kind type defaults to string +def test_custom_js_kind_unknown() -> None: + data = { + "js_kinds": { + "test": { + "title": "Test", + "description": "Test Description", + "type": "unknown", + } + } + } + jsnac = SchemaBuilder() + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["$defs"]["test"]["type"] == "string" + assert schema["$defs"]["test"]["title"] == "Error" + assert schema["$defs"]["test"]["description"] == "Invalid type (unknown), defaulted to string" + + +# Test all of our custom js_kind types +@pytest.mark.parametrize( + "js_kind", + [ + "ipv4", + "ipv6", + "ipv4_cidr", + "ipv6_cidr", + "ipv4_prefix", + "ipv6_prefix", + "domain", + "email", + "http_url", + "uint16", + "uint32", + "uint64", + "mtu", + "mac", + "mac_dot", + "vlan", + "docker_image", + ], +) +def test_custom_js_kind_types(js_kind) -> None: + jsnac = SchemaBuilder() + data = { + "schema": { + "test_object": { + "js_kind": {"name": js_kind}, + } + } + } + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["properties"]["test_object"]["$ref"] == f"#/$defs/{js_kind}" + + +# Test that general json schema types are generated correctly +@pytest.mark.parametrize( + "js_kind", + [ + "string", + "number", + "integer", + "boolean", + "null", + ], +) +def test_default_schema_types(js_kind) -> None: + jsnac = SchemaBuilder() + data = { + "schema": { + "test_object": { + "js_kind": {"name": js_kind}, + } + } + } + jsnac.add_json(json.dumps(data)) + schema = json.loads(jsnac.build_schema()) + assert schema["properties"]["test_object"]["type"] == f"{js_kind}" diff --git a/tests/test_cli.py b/tests/test_cli.py index b7af3d1..2ffb643 100755 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -50,3 +50,11 @@ def test_cli_file_json(capsys) -> None: main(["-f", "data/example-jsnac.json", "-j"]) output = capsys.readouterr() assert "JSNAC CLI complete" in output.err + + +# Test CLI with verbose argument +def test_cli_verbose(capsys) -> None: + with pytest.raises(SystemExit): + main(["-f", "data/example.yml", "-v"]) + output = capsys.readouterr() + assert "JSNAC CLI complete" in output.err diff --git a/tests/test_infer.py b/tests/test_infer.py deleted file mode 100755 index b5bf357..0000000 --- a/tests/test_infer.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -import json - -from jsnac.core.infer import SchemaInferer - - -# Test that custom headers can be set -def test_custom_headers() -> None: - data = { - "header": { - "schema": "http://json-schema.org/draft/2020-12/schema", - "title": "Test Title", - "id": "test-schema.json", - "description": "Test Description", - } - } - jsnac = SchemaInferer() - jsnac.add_json(json.dumps(data)) - schema = json.loads(jsnac.build_schema()) - assert schema["$schema"] == "http://json-schema.org/draft/2020-12/schema" - assert schema["title"] == "Test Title" - assert schema["$id"] == "test-schema.json" - assert schema["description"] == "Test Description" - - -# Test that default headers are set -def test_default_headers() -> None: - data = {"header": {}} - jsnac = SchemaInferer() - jsnac.add_json(json.dumps(data)) - schema = json.loads(jsnac.build_schema()) - assert schema["$schema"] == "http://json-schema.org/draft-07/schema#" - assert schema["title"] == "JSNAC created Schema" - assert schema["$id"] == "jsnac.schema.json" - assert schema["description"] == "https://github.com/commitconfirmed/jsnac" - assert schema["type"] == "object" - assert schema["properties"] == {} diff --git a/tests/test_schema.py b/tests/test_schema.py index 23dcc6f..2682383 100755 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -196,3 +196,37 @@ def test_invalid_domains(domain) -> None: domain_definitions = test_json_schema["$defs"]["domain"] with pytest.raises(jsonschema.exceptions.ValidationError): jsonschema.validate(domain, domain_definitions) + + +# Test the JSON schema with valid http and https URLs +@pytest.mark.parametrize( + "url", + [ + "http://example.com", + "https://example.com/", + "http://example.com/test", + "https://example.com/test/", + "http://example.com/test.html?query=1", + "https://example.com/test.js?query=1&query2=2", + ], +) +def test_valid_http_urls(url) -> None: + http_url_definitions = test_json_schema["$defs"]["http_url"] + jsonschema.validate(url, http_url_definitions) + assert True + + +# Test the JSON schema with some invalid http and https URLs, make sure they raise a ValidationError +@pytest.mark.parametrize( + "url", + [ + "http://example", + "https://example", + "htttp://example.com", + "httpss://example.com", + ], +) +def test_invalid_http_urls(url) -> None: + http_url_definitions = test_json_schema["$defs"]["http_url"] + with pytest.raises(jsonschema.exceptions.ValidationError): + jsonschema.validate(url, http_url_definitions) diff --git a/tox.ini b/tox.ini index 51f9a4b..dcbdeb5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{10,11,12} +envlist = py3{10,11,12,13} isolated_build = True skip_missing_interpreters = True