From dabc7eb9191893b8a4e21dca76bb5719505d01c4 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Sun, 1 Dec 2024 16:11:12 +1100 Subject: [PATCH 1/3] 0.2.1 development --- .coverage | Bin 53248 -> 53248 bytes .readthedocs.yaml | 2 +- Dockerfile | 2 +- README.md | 53 +++++++++--------- data/example-jsnac.json | 20 +++---- data/example-jsnac.yml | 20 +++---- data/regenerate_test_data.py | 4 +- dist/jsnac-0.2.1-py3-none-any.whl | Bin 0 -> 11201 bytes dist/jsnac-0.2.1.tar.gz | Bin 0 -> 13034 bytes docs/source/examples.rst | 9 +-- docs/source/intro.rst | 40 ++++++------- .../{jsnac.core.rst => jsnac.build.rst} | 4 +- jsnac/__init__.py | 6 +- jsnac/core/{infer.py => build.py} | 48 ++++++++-------- jsnac/utils/jsnac_cli.py | 6 +- pyproject.toml | 3 +- tests/test_infer.py | 6 +- tox.ini | 2 +- 18 files changed, 115 insertions(+), 110 deletions(-) create mode 100644 dist/jsnac-0.2.1-py3-none-any.whl create mode 100644 dist/jsnac-0.2.1.tar.gz rename docs/source/{jsnac.core.rst => jsnac.build.rst} (67%) rename jsnac/core/{infer.py => build.py} (91%) diff --git a/.coverage b/.coverage index 1dd0da0c64971fc5bb139c072f356a2073ee53b9..7ec8d1904122288b57578696ddbe88e54902baa5 100644 GIT binary patch delta 55 zcmZozz}&EadBZ|~)}+$RoRrPW{D1SqnDLedLI>|%zy0q1xBkEN3Cn+^ GzXJf%GaJkR delta 55 zcmZozz}&EadBZ|~*37)L)S}JH{D1SqnDLedLL0w--S&I?-{k-DH(u^5`r;|YwE1H{ GzXJfyW*bca 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..bb06915 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", @@ -20,19 +20,19 @@ "type": "object", "properties": { "hostname": { - "kind": { + "js_kind": { "name": "hostname" } }, "model": { - "kind": { + "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", @@ -55,7 +55,7 @@ "type": "object", "properties": { "domain_name": { - "kind": { + "js_kind": { "name": "string" } }, @@ -64,7 +64,7 @@ "description": "List of NTP servers", "type": "array", "items": { - "kind": { + "js_kind": { "name": "ipv4" } } @@ -83,22 +83,22 @@ "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..6821706 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" @@ -27,15 +27,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" @@ -46,13 +46,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" @@ -67,11 +67,11 @@ 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" ] \ No newline at end of file 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 0000000000000000000000000000000000000000..8db938fdcc6d759d0dc98090f8d06865b6a50d69 GIT binary patch literal 11201 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 + 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..1648502 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -49,35 +49,35 @@ You can simply write out how you would like to validate this data, and this prog 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 +90,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" @@ -108,15 +108,15 @@ We also have full support for writing your own titles, descriptions, kinds (sub- 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" @@ -127,13 +127,13 @@ We also have full support for writing your own titles, descriptions, kinds (sub- 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" @@ -148,13 +148,13 @@ We also have full support for writing your own titles, descriptions, kinds (sub- 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.build.rst similarity index 67% rename from docs/source/jsnac.core.rst rename to docs/source/jsnac.build.rst index 425e792..ba3c835 100644 --- a/docs/source/jsnac.core.rst +++ b/docs/source/jsnac.build.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/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/infer.py b/jsnac/core/build.py similarity index 91% rename from jsnac/core/infer.py rename to jsnac/core/build.py index 7b74484..2beb715 100755 --- a/jsnac/core/infer.py +++ b/jsnac/core/build.py @@ -7,9 +7,9 @@ import yaml -class SchemaInferer: +class SchemaBuilder: """ - SchemaInferer is a class that infers JSON schemas from provided JSON or YAML data. + SchemaBuilder 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. @@ -123,7 +123,7 @@ def add_yaml(self, yaml_data: str) -> None: def build_schema(self) -> str: """ - Builds a JSON schema based on the data added to the schema inferer. + 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. @@ -137,7 +137,7 @@ def build_schema(self) -> str: 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 + - 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. @@ -158,7 +158,7 @@ def build_schema(self) -> str: "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", {})), + "$defs": self._build_definitions(data.get("js_kinds", {})), "type": data.get("type", "object"), "additionalProperties": data.get("additionalProperties", False), "properties": self._build_properties(data.get("schema", {})), @@ -167,14 +167,14 @@ def build_schema(self) -> str: def _build_definitions(self, data: dict) -> dict: """ - Build a dictionary of definitions based on predefined types and additional kinds provided in the input data. + 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 kinds to be added to the definitions. + 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 kinds from the input data are also included. + Additional js_kinds from the input data are also included. Raises: None @@ -228,9 +228,9 @@ def _build_definitions(self, data: dict) -> dict: "description": "Domain name (String) \n Format: example.com", }, } - # Check passed data for additional kinds and add them to the definitions + # 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 kind (%s): \n%s ", kind, json.dumps(kind_data, indent=4)) + self.log.debug("Building custom js_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}") @@ -242,12 +242,14 @@ def _build_definitions(self, data: dict) -> dict: 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) + 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 kind (%s), defaulting to string", kind_data.get("type"), kind) + self.log.error( + "Invalid type (%s) for js_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 @@ -286,8 +288,8 @@ def _build_property(self, obj: str, obj_data: dict) -> dict: 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"])) + elif "js_kind" in obj_data: + property_dict.update(self._build_kinds(obj, obj_data["js_kind"])) if "required" in obj_data: property_dict["required"] = obj_data["required"] @@ -320,10 +322,10 @@ def _build_array_items(self, obj: str, obj_data: dict) -> dict: 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"]) + elif "js_kind" in item_data: + array_items["items"] = self._build_kinds(obj, item_data["js_kind"]) else: - self.log.error("Array items require a type or kind key") + self.log.error("Array items require a type or js_kind key") array_items["items"] = {"type": "null"} else: self.log.error("Array type requires an items key") @@ -332,7 +334,7 @@ def _build_array_items(self, obj: str, obj_data: dict) -> dict: 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)) + self.log.debug("Building js_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 @@ -357,8 +359,8 @@ def _build_kinds(self, obj: str, data: dict) -> dict: # noqa: C901 PLR0912 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" + 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": @@ -375,11 +377,11 @@ def _build_kinds(self, obj: str, data: dict) -> dict: # noqa: C901 PLR0912 kind["type"] = "null" kind["description"] = "Null" case _: - # Check if the kind is a user defined kind + # 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 kind (%s), defaulting to Null", data) - kind["description"] = f"Invalid kind ({data}), defaulting to Null" + self.log.error("Invalid js_kind (%s), defaulting to Null", data) + kind["description"] = f"Invalid js_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_infer.py b/tests/test_infer.py index b5bf357..dfffc34 100755 --- a/tests/test_infer.py +++ b/tests/test_infer.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import json -from jsnac.core.infer import SchemaInferer +from jsnac.core.build import SchemaBuilder # Test that custom headers can be set @@ -14,7 +14,7 @@ def test_custom_headers() -> None: "description": "Test Description", } } - jsnac = SchemaInferer() + 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" @@ -26,7 +26,7 @@ def test_custom_headers() -> None: # Test that default headers are set def test_default_headers() -> None: data = {"header": {}} - jsnac = SchemaInferer() + jsnac = SchemaBuilder() jsnac.add_json(json.dumps(data)) schema = json.loads(jsnac.build_schema()) assert schema["$schema"] == "http://json-schema.org/draft-07/schema#" 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 From 0e6d93ffc58a9ec066345d20823f45e1e2a9fbdd Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Sat, 7 Dec 2024 17:25:44 +1100 Subject: [PATCH 2/3] 0.2.1 development --- .coverage | Bin 53248 -> 53248 bytes .github/workflows/main.yml | 2 +- data/example-jsnac.json | 6 +- data/example-jsnac.yml | 8 +- data/example.schema.json | 99 +++++++--- docs/source/types.rst | 98 +++++++--- jsnac/core/build.py | 370 +++++++++++++++++++------------------ tests/test_build.py | 181 ++++++++++++++++++ tests/test_cli.py | 8 + tests/test_infer.py | 37 ---- tests/test_schema.py | 34 ++++ 11 files changed, 574 insertions(+), 269 deletions(-) create mode 100755 tests/test_build.py delete mode 100755 tests/test_infer.py diff --git a/.coverage b/.coverage index 7ec8d1904122288b57578696ddbe88e54902baa5..d5eedc9c365cb254bf33ca7ea87ca5baaca834cb 100644 GIT binary patch delta 108 zcmV-y0F(cKpaX!Q1F!~w43q#5`490Aw-2uml(P{Kf)A6Hj}}MUzxVwF1_T5F2@V7T zI0FO$333Ct3;+NC1;FkE;9mg#F8~1W<-Y;|`}n^1-h1!+zVE&7@%{e3006o6F90?G O0H#O(|Aw>ij}JhcLoKub delta 106 zcmZozz}&Eac>`Mm&ol=9pZp*Bck*xKpSD?0poM?(lzuVSoBO~2Wnp7sVdUgvVKQR| z(s|4~co=}7fhnP!>4O48yEp?w$ESG4hT59@-)rUX^B=r-{r0>2-}?X7CoKPc>Y{ok K$L5dy{0;!A`YDzG 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/data/example-jsnac.json b/data/example-jsnac.json index bb06915..d7282b2 100644 --- a/data/example-jsnac.json +++ b/data/example-jsnac.json @@ -17,7 +17,6 @@ "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": { "js_kind": { @@ -25,6 +24,7 @@ } }, "model": { + "description": "Model of the device", "js_kind": { "name": "string" } @@ -52,7 +52,6 @@ "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": { "js_kind": { @@ -62,7 +61,6 @@ "ntp_servers": { "title": "NTP Servers", "description": "List of NTP servers", - "type": "array", "items": { "js_kind": { "name": "ipv4" @@ -78,9 +76,7 @@ "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": { "js_kind": { diff --git a/data/example-jsnac.yml b/data/example-jsnac.yml index 6821706..f17715a 100644 --- a/data/example-jsnac.yml +++ b/data/example-jsnac.yml @@ -24,11 +24,11 @@ schema: hostname [required]: hostname model [required]: string device_type [required]: choice (router, switch, firewall, load-balancer) - type: "object" properties: hostname: js_kind: { name: "hostname" } model: + description: "Model of the device" js_kind: { name: "string" } device_type: title: "Device Type" @@ -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: js_kind: { name: "string" } ntp_servers: title: "NTP Servers" description: "List of NTP servers" - type: "array" items: - js_kind: { name: "ipv4" } + js_kind: { name: "ipv4" } required: [ "domain_name", "ntp_servers" ] interfaces: title: "Device Interfaces" @@ -62,9 +60,7 @@ schema: desc: string ipv4: ipv4_cidr ipv6: ipv6_cidr - type: "array" items: - type: "object" properties: if: js_kind: { name: "string" } 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/docs/source/types.rst b/docs/source/types.rst index 74cd105..791e6d4 100644 --- a/docs/source/types.rst +++ b/docs/source/types.rst @@ -3,62 +3,65 @@ 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" } -kind: domain +js_kind: ipv6 ****************** -This type is used to validate a string against a domain name. -The string will be validated against the below domain name regex pattern. +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-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$ + ^(([a-fA-F0-9]{1,4}|):){1,7}([a-fA-F0-9]{1,4}|:)$ **Example** .. code-block:: yaml system: - domain_name: - kind: { name: "domain" } + ip_address: + js_kind: { name: "ipv6" } -kind: ipv4 +js_kind: ipv4_cidr ****************** -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 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][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$ + ^((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** @@ -66,4 +69,51 @@ The string will be validated against the below IPv4 address regex pattern. system: ip_address: - kind: { name: "ipv4" } \ No newline at end of file + 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)$ + + +js_kind: mac +****************** + +This type is used to validate a string against a MAC address. +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: domain +****************** + +This type is used to validate a string against a domain name. +The string will be validated against the below domain name regex pattern. + +.. code-block:: text + + ^([a-zA-Z0-9-]{1,63}\\.)+[a-zA-Z]{2,63}$ + +**Example** + +.. code-block:: yaml + + system: + domain_name: + js_kind: { name: "domain" } \ No newline at end of file diff --git a/jsnac/core/build.py b/jsnac/core/build.py index 2beb715..8ee8ebd 100755 --- a/jsnac/core/build.py +++ b/jsnac/core/build.py @@ -2,53 +2,38 @@ import json import logging -from typing import ClassVar +from typing import Any, ClassVar import yaml class SchemaBuilder: """ - SchemaBuilder is a class that infers JSON schemas from provided JSON or YAML data. + SchemaBuilder is a class designed to build JSON schemas from JSON or YAML data. + It supports predefined types and allows for user-defined kinds. - user_defined_kinds (dict): A class variable that stores 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: - Returns the user-defined kinds currently stored in the class variable. - + Class method to view any user-defined kinds. _add_user_defined_kinds(kinds: dict) -> None: - Adds user-defined kinds to the class variable. - + 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: - Builds a JSON schema based on the data added to the schema inferer. Returns the constructed schema. - + 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 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. + 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. """ @@ -91,7 +76,7 @@ def add_json(self, json_data: str) -> None: """ try: load_json_data = json.loads(json_data) - self.log.debug("JSON content: \n%s", json.dumps(load_json_data, indent=4)) + 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 @@ -145,12 +130,12 @@ def build_schema(self) -> str: """ # 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." + 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 ", json.dumps(data, indent=4)) + 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 = { @@ -161,7 +146,7 @@ def build_schema(self) -> str: "$defs": self._build_definitions(data.get("js_kinds", {})), "type": data.get("type", "object"), "additionalProperties": data.get("additionalProperties", False), - "properties": self._build_properties(data.get("schema", {})), + "properties": self._build_properties("Default", data.get("schema", {})), } return json.dumps(schema, indent=4) @@ -180,9 +165,9 @@ def _build_definitions(self, data: dict) -> dict: None """ - self.log.debug("Building definitions for: \n%s ", json.dumps(data, indent=4)) - definitions = { - # JSNAC defined data types + 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 @@ -195,42 +180,93 @@ def _build_definitions(self, data: dict) -> dict: "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 + "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) \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", }, } # 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, json.dumps(kind_data, indent=4)) + 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}") @@ -251,137 +287,121 @@ def _build_definitions(self, data: dict) -> dict: "Invalid type (%s) for js_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)) + 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, 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 "js_kind" in obj_data: - property_dict.update(self._build_kinds(obj, obj_data["js_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 "js_kind" in item_data: - array_items["items"] = self._build_kinds(obj, item_data["js_kind"]) - else: - self.log.error("Array items require a type or js_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_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. - def _build_kinds(self, obj: str, data: dict) -> dict: # noqa: C901 PLR0912 - self.log.debug("Building js_kinds for Object (%s): \n%s ", obj, json.dumps(data, indent=4)) + 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 = {} - # 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 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["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 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), defaulting to Null", data) - kind["description"] = f"Invalid js_kind ({data}), defaulting to Null" + # 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/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 dfffc34..0000000 --- a/tests/test_infer.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -import json - -from jsnac.core.build import SchemaBuilder - - -# 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"] == {} 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) From 35ec61114389360ccec43bb93e483af75e8dfde1 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Sun, 8 Dec 2024 11:36:00 +1100 Subject: [PATCH 3/3] 0.2.1 release --- .gitignore | 5 +- dist/jsnac-0.2.1-py3-none-any.whl | Bin 11201 -> 11583 bytes dist/jsnac-0.2.1.tar.gz | Bin 13034 -> 13371 bytes docs/source/conf.py | 2 +- docs/source/intro.rst | 12 +- .../{jsnac.build.rst => jsnac.core.rst} | 0 docs/source/types.rst | 158 +++++++++++++++++- 7 files changed, 157 insertions(+), 20 deletions(-) rename docs/source/{jsnac.build.rst => jsnac.core.rst} (100%) 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/dist/jsnac-0.2.1-py3-none-any.whl b/dist/jsnac-0.2.1-py3-none-any.whl index 8db938fdcc6d759d0dc98090f8d06865b6a50d69..335a3561b667a268e1e6b4a7e14f04dd75fc4ca4 100644 GIT binary patch delta 5074 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 delta 4680 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 K2Hq$D0001Sg3m4h diff --git a/dist/jsnac-0.2.1.tar.gz b/dist/jsnac-0.2.1.tar.gz index 8ed16bbb5f19f246d88a3341b107e2daa29d270a..2435dbb5dcf419fa97322e4c7aa8ce8c3a2d14eb 100644 GIT binary patch literal 13371 zcmZA7V~{R9)F$e-ZQHhO+s1D1wr$(CwcECB+qT`$dFMNI>dZ_km8v9_Kl!z4-4|gr z6co^Z0|a30Y-emr&&0sOz|7!c>;!b<>+65in&<#HEf+dD){r5;9BJ%XWwg6o-~Fdk zcawi3pEdSiWd)s-Q7V;Ma=mhM>-(CG830CoIa1lwb7s==AVbE41!oSy+HYjEcJpb- zkb_uP%9?OM=$EV*EI9RR%htMe;qJ5hw>hg;Fapv49VA~HjhC1W5WaU-{-Pph;!c8? z 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 literal 13034 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