From b6becb9b756a54586ff1dff06bcdae23cdb03fbf Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Fri, 29 Nov 2024 23:28:00 +1100 Subject: [PATCH 1/3] 0.2.0 development --- data/example-jsnac.json | 137 ++++++++++---- data/example-jsnac.yml | 70 +++++-- data/example.schema.json | 36 ++-- data/example.yml | 2 +- data/regenerate_test_data.py | 2 +- manage.sh => docker.sh | 0 docs/source/conf.py | 2 +- jsnac/core/infer.py | 341 +++++++++++++++++++---------------- jsnac/utils/jsnac_cli.py | 2 +- pyproject.toml | 4 +- tests/test_infer.py | 61 ++++--- 11 files changed, 407 insertions(+), 250 deletions(-) rename manage.sh => docker.sh (100%) diff --git a/data/example-jsnac.json b/data/example-jsnac.json index c1f0e9d..0e65c49 100644 --- a/data/example-jsnac.json +++ b/data/example-jsnac.json @@ -1,44 +1,111 @@ { - "chassis": { + "header": { + "id": "example-schema.json", + "schema": "http://json-schema.org/draft-07/schema", + "title": "JSNAC Created Schema", + "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)" + }, + "kinds": { "hostname": { - "jsnac_type": "pattern", - "jsnac_pattern": "^ceos-[a-zA-Z]{1,16}[0-9]$" - }, - "model": "ceos", - "type": { - "jsnac_type": "choice", - "jsnac_choices": [ - "router", - "switch", - "spine", - "leaf" - ] + "title": "Hostname", + "description": "Hostname of the device", + "type": "pattern", + "regex": "^[a-zA-Z0-9-]{1,63}$" } }, - "system": { - "domain_name": { - "jsnac_type": "domain" - }, - "ntp_servers": [ - { - "jsnac_type": "ipv4" - } - ] - }, - "interfaces": [ - { - "if": { - "jsnac_type": "string" + "schema": { + "chassis": { + "title": "Chassis", + "description": "Chassis information", + "type": "object", + "properties": { + "hostname": { + "kind": { + "name": "hostname" + } + }, + "model": { + "kind": { + "name": "string" + } + }, + "device_type": { + "title": "Type", + "description": "Type of the device", + "kind": { + "name": "choice", + "choices": [ + "router", + "switch", + "firewall", + "load-balancer" + ] + } + } }, - "desc": { - "jsnac_type": "string" - }, - "ipv4": { - "jsnac_type": "ipv4_cidr" + "required": [ + "hostname", + "model", + "type" + ] + }, + "system": { + "title": "System", + "description": "System information", + "type": "object", + "properties": { + "domain_name": { + "kind": { + "name": "string" + } + }, + "ntp_servers": { + "title": "NTP Servers", + "description": "List of NTP servers", + "type": "array", + "items": { + "kind": { + "name": "ipv4" + } + } + } }, - "ipv6": { - "jsnac_type": "ipv6_cidr" + "required": [ + "domain_name", + "ntp_servers" + ] + }, + "interfaces": { + "title": "Interfaces", + "type": "array", + "items": { + "type": "object", + "properties": { + "if": { + "kind": { + "name": "string" + } + }, + "desc": { + "kind": { + "name": "string" + } + }, + "ipv4": { + "kind": { + "name": "ipv4_cidr" + } + }, + "ipv6": { + "kind": { + "name": "ipv6_cidr" + } + } + }, + "required": [ + "if" + ] } } - ] + } } \ No newline at end of file diff --git a/data/example-jsnac.yml b/data/example-jsnac.yml index b29215d..df4ff00 100644 --- a/data/example-jsnac.yml +++ b/data/example-jsnac.yml @@ -1,14 +1,58 @@ -chassis: - hostname: { jsnac_type: pattern, jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" } - model: "ceos" - type: { jsnac_type: choice, jsnac_choices: ["router", "switch", "spine", "leaf"] } +--- +header: + id: "example-schema.json" + schema: "http://json-schema.org/draft-07/schema" + title: "JSNAC Created Schema" + description: "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)" -system: - domain_name: { jsnac_type: domain } - ntp_servers: [ { jsnac_type: ipv4 } ] - -interfaces: - - if: { jsnac_type: string } - desc: { jsnac_type: string } - ipv4: { jsnac_type: ipv4_cidr } - ipv6: { jsnac_type: ipv6_cidr } \ No newline at end of file +kinds: + hostname: + title: "Hostname" + description: "Hostname of the device" + type: "pattern" + regex: "^[a-zA-Z0-9-]{1,63}$" + +schema: + chassis: + title: "Chassis" + description: "Chassis information" + type: "object" + properties: + hostname: + kind: { name: "hostname" } + model: + kind: { name: "string" } + device_type: + title: "Type" + description: "Type of the device" + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + required: [ "hostname", "model", "type" ] + system: + title: "System" + description: "System information" + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + title: "NTP Servers" + description: "List of NTP servers" + type: "array" + items: + kind: { name: "ipv4" } + required: [ "domain_name", "ntp_servers" ] + interfaces: + title: "Interfaces" + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + 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 3076164..92368e7 100644 --- a/data/example.schema.json +++ b/data/example.schema.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", "title": "JSNAC Created Schema", + "$id": "example-schema.json", "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)", "$defs": { "ipv4": { @@ -50,42 +51,49 @@ "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", "title": "String", "description": "Alphanumeric string with special characters (String) \n Max length: 255" + }, + "hostname": { + "title": "Hostname", + "description": "Hostname of the device", + "type": "string", + "pattern": "^[a-zA-Z0-9-]{1,63}$" } }, "type": "object", "additionalProperties": false, "properties": { "chassis": { + "title": "Chassis", + "description": "Chassis information", "type": "object", "properties": { "hostname": { - "type": "string", - "pattern": "^ceos-[a-zA-Z]{1,16}[0-9]$", - "title": "Custom Pattern", - "description": "Custom Pattern (regex) \n Pattern: ^ceos-[a-zA-Z]{1,16}[0-9]$" + "$ref": "#/$defs/hostname" }, "model": { "type": "string" }, - "type": { + "device_type": { "enum": [ "router", "switch", - "spine", - "leaf" - ], - "title": "Custom Choice", - "description": "Custom Choice (enum) \n Choices: router, switch, spine, leaf" + "firewall", + "load-balancer" + ] } } }, "system": { + "title": "System", + "description": "System information", "type": "object", "properties": { "domain_name": { - "$ref": "#/$defs/domain" + "type": "string" }, "ntp_servers": { + "title": "NTP Servers", + "description": "List of NTP servers", "type": "array", "items": { "$ref": "#/$defs/ipv4" @@ -94,15 +102,17 @@ } }, "interfaces": { + "title": "Interfaces", + "description": "Object: interfaces", "type": "array", "items": { "type": "object", "properties": { "if": { - "$ref": "#/$defs/string" + "type": "string" }, "desc": { - "$ref": "#/$defs/string" + "type": "string" }, "ipv4": { "$ref": "#/$defs/ipv4_cidr" diff --git a/data/example.yml b/data/example.yml index 8b285c1..e70c747 100644 --- a/data/example.yml +++ b/data/example.yml @@ -1,5 +1,5 @@ # yaml-language-server: $schema=example.schema.json - +--- chassis: hostname: "ceos-spine1" model: "ceos" diff --git a/data/regenerate_test_data.py b/data/regenerate_test_data.py index 03fcc85..36a2cb9 100755 --- a/data/regenerate_test_data.py +++ b/data/regenerate_test_data.py @@ -57,7 +57,7 @@ def main() -> None: # noqa: D103 with example_jsnac_file.open() as f: jsnac = SchemaInferer() jsnac.add_yaml(f.read()) - schema = jsnac.build() + schema = jsnac.build_schema() f.close() with output_schema_file.open(mode="w") as f: f.write(schema) diff --git a/manage.sh b/docker.sh similarity index 100% rename from manage.sh rename to docker.sh diff --git a/docs/source/conf.py b/docs/source/conf.py index c0b95f5..2dc3187 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.1.0" +release = "0.2.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/jsnac/core/infer.py b/jsnac/core/infer.py index ae0dec8..0fdf43a 100755 --- a/jsnac/core/infer.py +++ b/jsnac/core/infer.py @@ -28,6 +28,8 @@ class SchemaInferer: """ + user_defined_kinds: dict = {} + def __init__(self) -> None: """ Initializes the instance of the class. @@ -43,6 +45,14 @@ def __init__(self) -> None: self.log = logging.getLogger(__name__) self.log.addHandler(logging.NullHandler()) + @classmethod + def access_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: """ @@ -87,7 +97,7 @@ def add_yaml(self, yaml_data: str) -> None: self.log.debug("JSON content: \n %s", json_dump) self.data = json_data - def build(self) -> str: + def build_schema(self) -> str: """ Builds a JSON schema based on the data added to the schema inferer. @@ -112,162 +122,185 @@ def build(self) -> str: 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. - # I want to fix this with a flag to the CLI to allow you to choose the draft version you want to use - # in the future (or insert your own). schema = { - "$schema": "http://json-schema.org/draft-07/schema", - "title": "JSNAC Created Schema", - "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)", - "$defs": { - "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", # noqa: E501 - }, - "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", - }, - # String is a default type, but in this instance we restict it to - # alphanumeric + special characters with a max length of 255. - "string": { - "type": "string", - "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", - "title": "String", - "description": "Alphanumeric string with special characters (String) \n Max length: 255", - }, - }, - "type": "object", - "additionalProperties": False, - "properties": self.infer_properties(data)["properties"], + "$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 infer_properties(self, data: str) -> dict: # noqa: C901 PLR0912 PLR0915 (To be fixed) - """ - Infers the JSON schema properties for the given data. - - This method analyzes the input data and generates a corresponding JSON schema. - It supports custom schema definitions based on the "jsnac_type" key in the input dictionary. - - Args: - data (str): The input data to infer the schema from. - - Returns: - dict: A dictionary representing the inferred JSON schema. - - Schema Inference rules (based on the input data type): - - Is a dictionary and contains the "jsnac_type" key, the method uses custom schema definitions - - Is a dictionary without the "jsnac_type" key, we infer the schema recursively for each key-value pair. - - Is a list, the method infers the schema for the first item in the list. - - Is a string, integer, float, or boolean, the method infers the corresponding JSON schema type. - - Is Of an unrecognized type, the method defaults to a null schema. - - """ - schema = {} - # Check if the dictionary has a jsnac_type key in it, then we know we can use our custom schema definitions - if isinstance(data, dict): - if "jsnac_type" in data: # Split this out into a separate method to be ruff compliant - match data["jsnac_type"]: - case "ipv4": - schema["$ref"] = "#/$defs/ipv4" - case "ipv6": - schema["$ref"] = "#/$defs/ipv6" - case "ipv4_cidr": - schema["$ref"] = "#/$defs/ipv4_cidr" - case "ipv6_cidr": - schema["$ref"] = "#/$defs/ipv6_cidr" - case "ipv4_prefix": - schema["$ref"] = "#/$defs/ipv4_prefix" - case "ipv6_prefix": - schema["$ref"] = "#/$defs/ipv6_prefix" - case "domain": - schema["$ref"] = "#/$defs/domain" - case "string": - schema["$ref"] = "#/$defs/string" - case "pattern": - if "jsnac_pattern" not in data: - self.log.error("jsnac_pattern key is required for jsnac_type: pattern.") - schema["type"] = "null" - schema["title"] = "Error" - schema["description"] = "No jsnac_pattern key provided" - else: - schema["type"] = "string" - schema["pattern"] = data["jsnac_pattern"] - schema["title"] = "Custom Pattern" - schema["description"] = "Custom Pattern (regex) \n Pattern: " + data["jsnac_pattern"] - case "choice": - if "jsnac_choices" not in data: - self.log.error("jsnac_choices key is required for jsnac_type: choice.") - schema["enum"] = "Error" - schema["title"] = "Error" - schema["description"] = "No jsnac_choices key provided" - else: - schema["enum"] = data["jsnac_choices"] - schema["title"] = "Custom Choice" - schema["description"] = "Custom Choice (enum) \n Choices: " + ", ".join( - data["jsnac_choices"] + def _build_definitions(self, data: dict) -> dict: + 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", + }, + # String is a default type, but in this instance we restict it to + # alphanumeric + special characters with a max length of 255. + "string": { + "type": "string", + "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", + "title": "String", + "description": "Alphanumeric string with special characters (String) \n Max length: 255", + }, + } + # Check passed data for additional kinds and add them to the definitions + for kind, kind_data in data.items(): + self.log.debug("Kind: %s ", kind) + self.log.debug("Kind Data: %s ", kind_data) + # Add the kind to the definitions + definitions[kind] = {} + definitions[kind]["title"] = kind_data.get("title", "%s" % kind) + definitions[kind]["description"] = kind_data.get("description", "Custom Kind: %s" % 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 = {} + for object, object_data in data.items(): + self.log.debug("Object: %s ", object) + self.log.debug("Object Data: %s ", object_data) + # Think of a way to have better defaults for title and description + # Also, inner properties aren't getting a default description for some reason? + properties[object] = {} + properties[object]["title"] = object_data.get("title", "%s" % object) + properties[object]["description"] = object_data.get("description", "Object: %s" % object) + # Check if our object has a type, if so we will continue to dig depper until kinds are found + if "type" in object_data: + match object_data.get("type"): + case "object": + properties[object]["type"] = "object" + if "properties" in object_data: + properties[object]["properties"] = self._build_properties(object_data["properties"]) + case "array": + properties[object]["type"] = "array" + # Check if the array contains an object type, if so we will build the properties for it + if "type" in object_data["items"]: + properties[object]["items"] = {} + properties[object]["items"]["type"] = object_data["items"]["type"] + properties[object]["items"]["properties"] = self._build_properties( + object_data["items"]["properties"] ) + # Otherwise its just a list of a specific kind + elif "kind" in object_data["items"]: + properties[object]["items"] = self._build_kinds(object_data["items"]["kind"]) case _: - self.log.error("Invalid jsnac_type: (%s), defaulting to null", data["jsnac_type"]) - schema["type"] = "null" - schema["title"] = "Error" - schema["description"] = "Invalid jsnac_type (" + data["jsnac_type"] + ") defined" - # If not, simply continue inferring the schema - else: - schema["type"] = "object" - schema["properties"] = {k: self.infer_properties(v) for k, v in data.items()} - - elif isinstance(data, list): - if len(data) > 0: - schema["type"] = "array" - schema["items"] = self.infer_properties(data[0]) - else: - schema["type"] = "array" - schema["items"] = {} - elif isinstance(data, str): - schema["type"] = "string" - elif isinstance(data, int): - schema["type"] = "integer" - elif isinstance(data, float): - schema["type"] = "number" - elif isinstance(data, bool): - schema["type"] = "boolean" - else: - schema["type"] = "null" - return schema + self.log.error( + "Invalid type (%s) for object (%s), defaulting to Null", object_data.get("type"), object + ) + properties[object]["type"] = "null" + # We've reached an object with a kind key, we can now build the reference based on the kind + elif "kind" in object_data: + kind = self._build_kinds(object_data["kind"]) + properties[object] = kind + self.log.debug("Returned Properties: \n %s ", json.dumps(properties, indent=4)) + return properties + + def _build_kinds(self, data: dict) -> dict: # noqa: C901 PLR0912 + self.log.debug("Building kinds for: \n %s ", 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 key + case "choice": + if "choices" in data: + kind["enum"] = data["choices"] + else: + self.log.error("Choice kind requires a choices key") + kind["type"] = "null" + # Default types + case "string": + kind["type"] = "string" + case "number": + kind["type"] = "number" + case "boolean": + kind["type"] = "boolean" + case "null": + kind["type"] = "null" + case _: + # Check if the kind is a user defined kind + if data.get("name") in self.access_user_defined_kinds(): + kind["$ref"] = "#/$defs/{}".format(data["name"]) + else: + self.log.error("Invalid kind (%s), defaulting to Null", data) + kind["type"] = "null" + return kind diff --git a/jsnac/utils/jsnac_cli.py b/jsnac/utils/jsnac_cli.py index f0f9e0f..8dff9cb 100755 --- a/jsnac/utils/jsnac_cli.py +++ b/jsnac/utils/jsnac_cli.py @@ -138,7 +138,7 @@ def main(args: str | None = None) -> None: f.close() # Build the schema and record the time taken tic = time.perf_counter() - schema = jsnac.build() + schema = jsnac.build_schema() toc = time.perf_counter() duration = toc - tic log.info("Schema built in %.4f seconds", duration) diff --git a/pyproject.toml b/pyproject.toml index fd46763..5f85ff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "jsnac" -version = "0.1.0" -description = "JSON Schema (for) Network as Code: Build JSON schemas from (un)modified YAML files" +version = "0.2.0" +description = "JSON Schema (for) Network as Code: Build JSON schemas from YAML" authors = ["Andrew Jones "] license = "MIT" readme = "README.md" diff --git a/tests/test_infer.py b/tests/test_infer.py index 1538e7c..b5bf357 100755 --- a/tests/test_infer.py +++ b/tests/test_infer.py @@ -1,34 +1,37 @@ #!/usr/bin/env python3 -from jsnac.core.infer import SchemaInferer - - -# Test SchemaInferer with jsnac_type: ipv4 -def test_infer_ipv4() -> None: - data = {"jsnac_type": "ipv4"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv4"} - +import json -# Test SchemaInferer with jsnac_type: ipv6 -def test_infer_ipv6() -> None: - data = {"jsnac_type": "ipv6"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv6"} - - -# Test SchemaInferer with jsnac_type: ipv4_cidr -def test_infer_ipv4_cidr() -> None: - data = {"jsnac_type": "ipv4_cidr"} - schema = SchemaInferer().infer_properties(data) - assert schema == {"$ref": "#/$defs/ipv4_cidr"} +from jsnac.core.infer import SchemaInferer -# Test SchemaInferer with an invalid jsnac_type -def test_infer_invalid_type() -> None: - data = {"jsnac_type": "invalid"} - schema = SchemaInferer().infer_properties(data) - assert schema == { - "type": "null", - "title": "Error", - "description": "Invalid jsnac_type (invalid) defined", +# 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"] == {} From 39455547c59f0e9e51f99c226c10165f4788a665 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Sat, 30 Nov 2024 23:49:17 +1100 Subject: [PATCH 2/3] 0.2.0 release --- .coverage | Bin 53248 -> 53248 bytes README.md | 167 +++++++++++++++------ data/example-jsnac.json | 17 ++- data/example-jsnac.yml | 35 ++++- data/example.json | 2 +- data/example.schema.json | 54 ++++--- data/example.yml | 2 +- dist/jsnac-0.2.0-py3-none-any.whl | Bin 0 -> 10275 bytes dist/jsnac-0.2.0.tar.gz | Bin 0 -> 11340 bytes docs/source/examples.rst | 2 +- docs/source/intro.rst | 158 ++++++++++++++------ docs/source/types.rst | 41 ++++-- jsnac/core/infer.py | 233 ++++++++++++++++++++---------- 13 files changed, 495 insertions(+), 216 deletions(-) create mode 100644 dist/jsnac-0.2.0-py3-none-any.whl create mode 100644 dist/jsnac-0.2.0.tar.gz diff --git a/.coverage b/.coverage index a65443a16dd033e457636d31e25d9e0b83bb56e1..1dd0da0c64971fc5bb139c072f356a2073ee53b9 100644 GIT binary patch delta 92 zcmV-i0HgnapaX!Q1F!~w43+>7`490Axeu`qma`EMgb$OXk0UoW0|WsHZUeXs00000 yfC6B50`MRJhZg_ArIGNgA{8j}SoKZ6#m; delta 78 zcmV-U0I~mopaX!Q1F!~w45R=L`490A#}B^`q_Ytak`I%Xk0U840|WsHQUh290Du6P k06Yl5;ROIh;eUMJ_kACqD*yoW|N6lX-e->iv+$1)Kr{gzV*mgE diff --git a/README.md b/README.md index 115c738..711a3a0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ JSON Schema (for) Network as Code The majority of Network and Infrastructure automation is done in YAML. Be it Ansible Host Variables, Network Device data to build a Jinja2 configuration with, or just a collection of data you want to run a script against to put into another product you have likely written a YAML file and have had to debug a typo or had to help another colleague build a new file for a new device. -In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every case and generally don't play nice with a GIT CI/CD process so you still will likely end up creating some YAML files here and there. +In an ideal world you can (and should) put a lot of this data into a database or Network Source of Truth solution and pull from it so the validation is done for you. However, these solutions don't cover every use case so you will likely end up creating some YAML files here and there. -Using a JSON schema for validating your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. +Using a JSON schema for validating & documenting your YAML is a good practice in a CI/CD world but is very cumbersome to create from scratch. -This project aims to simplify this whole process to help you build a decent JSON schema base from a YAML file with network and infrastructure data definitions in mind. +This project aims to simplify this whole process by helping you build a JSON schema using YAML syntax that has network and infrastructure templates (or sub-schemas) in mind. Now you can hopefully catch those rare mistakes before you run that Playbook, create that configuration with a Jinja2 template or run a REST query to that Source of Truth or Telemetry solution :) @@ -24,7 +24,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" @@ -40,55 +40,128 @@ interfaces: ipv4: "10.1.0.20/24" ``` -You can simply write out how you would like to validate this data, and this program will write out a JSON schema you can use. You can just also keep your existing data if you just want some basic type validation (string, integer, float, array, etc.). +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. ```yaml -chassis: - hostname: - jsnac_type: pattern - jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" - model: "ceos" - type: - jsnac_type: choice - jsnac_choices: ["router", "switch", "spine", "leaf"] - -system: - domain_name: - jsnac_type: domain - ntp_servers: - jsnac_type: ipv4 - -interfaces: - - if: - jsnac_type: string - desc: - jsnac_type: string - ipv4: - jsnac_type: ipv4_cidr - ipv6: - jsnac_type: ipv6_cidr +header: + title: "Ansible host vars" + +schema: + chassis: + title: "Chassis" + type: "object" + properties: + hostname: + kind: { name: "string" } + model: + kind: { name: "string" } + device_type: + kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } + system: + type: "object" + properties: + domain_name: + kind: { name: "string" } + ntp_servers: + type: "array" + items: + kind: { name: "ipv4" } + interfaces: + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } ``` -Alternatively, you can also just use dictionaries inplace: +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: ```yaml -chassis: - hostname: { jsnac_type: pattern, jsnac_pattern: "^ceos-[a-zA-Z]{1,16}[0-9]$" } - model: "ceos" - type: { jsnac_type: choice, jsnac_choices: ["router", "switch", "spine", "leaf"] } - -system: - domain_name: { jsnac_type: domain } - ntp_servers: { jsnac_type: ipv4 } - -interfaces: - - if: { jsnac_type: string } - desc: { jsnac_type: string } - ipv4: { jsnac_type: ipv4_cidr } - ipv6: { jsnac_type: ipv6_cidr } +header: + id: "example-schema.json" + title: "Ansible host vars" + description: | + Ansible host vars for my networking device. Requires the below objects: + - chassis + - system + - interfaces + +kinds: + hostname: + title: "Hostname" + description: "Hostname of the device" + type: "pattern" + regex: "^[a-zA-Z0-9-]{1,63}$" + +schema: + chassis: + title: "Chassis" + description: | + Object containing Chassis information. Has the below properties: + hostname [required]: hostname + model [required]: string + device_type [required]: choice (router, switch, firewall, load-balancer) + type: "object" + properties: + hostname: + kind: { name: "hostname" } + model: + 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" ] } + required: [ "hostname", "model", "device_type" ] + system: + title: "System" + description: | + 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" } + ntp_servers: + title: "NTP Servers" + description: "List of NTP servers" + type: "array" + items: + kind: { name: "ipv4" } + required: [ "domain_name", "ntp_servers" ] + interfaces: + title: "Device Interfaces" + description: | + List of device interfaces. Each interface has the below properties: + if [required]: string + desc: string + ipv4: ipv4_cidr + ipv6: ipv6_cidr + type: "array" + items: + type: "object" + properties: + if: + kind: { name: "string" } + desc: + kind: { name: "string" } + ipv4: + kind: { name: "ipv4_cidr" } + ipv6: + kind: { name: "ipv6_cidr" } + required: [ "if" ] ``` -A full list of jsnac_types is available in the documentation (readthedocs coming soon) +A full list of kinds are available in the ![documentation](https://jsnac.readthedocs.io/en/latest/) ## Usage @@ -104,7 +177,7 @@ jsnac -f data/example-jsnac.yml # Build a JSON schema from a YAML file and save it to a custom file jsnac -f data/example-jsnac.yml -o my.schema.json -# Increase the verbosity of the output +# Increase the verbosity of the output (this generates alot of messages as I use it for debugging) jsnac -f data/example-jsnac.yml -v ``` @@ -129,7 +202,7 @@ def main(): # jsnac.add_json(json_data) # Build the JSON schema - schema = jsnac.build() + schema = jsnac.build_schema() print(schema) if __name__ == '__main__': diff --git a/data/example-jsnac.json b/data/example-jsnac.json index 0e65c49..eafccbb 100644 --- a/data/example-jsnac.json +++ b/data/example-jsnac.json @@ -2,8 +2,8 @@ "header": { "id": "example-schema.json", "schema": "http://json-schema.org/draft-07/schema", - "title": "JSNAC Created Schema", - "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)" + "title": "Example Schema", + "description": "Ansible host vars for my networking device. Requires the below objects:\n- chassis\n- system\n- interfaces\n" }, "kinds": { "hostname": { @@ -16,7 +16,7 @@ "schema": { "chassis": { "title": "Chassis", - "description": "Chassis information", + "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": { @@ -30,8 +30,8 @@ } }, "device_type": { - "title": "Type", - "description": "Type of the device", + "title": "Device Type", + "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", "kind": { "name": "choice", "choices": [ @@ -46,12 +46,12 @@ "required": [ "hostname", "model", - "type" + "device_type" ] }, "system": { "title": "System", - "description": "System information", + "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": { @@ -76,7 +76,8 @@ ] }, "interfaces": { - "title": "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", diff --git a/data/example-jsnac.yml b/data/example-jsnac.yml index df4ff00..dd13cf0 100644 --- a/data/example-jsnac.yml +++ b/data/example-jsnac.yml @@ -2,8 +2,12 @@ header: id: "example-schema.json" schema: "http://json-schema.org/draft-07/schema" - title: "JSNAC Created Schema" - description: "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)" + title: "Example Schema" + description: | + Ansible host vars for my networking device. Requires the below objects: + - chassis + - system + - interfaces kinds: hostname: @@ -15,7 +19,11 @@ kinds: schema: chassis: title: "Chassis" - description: "Chassis information" + description: | + Object containing Chassis information. Has the below properties: + hostname [required]: hostname + model [required]: string + device_type [required]: choice (router, switch, firewall, load-balancer) type: "object" properties: hostname: @@ -23,13 +31,18 @@ schema: model: kind: { name: "string" } device_type: - title: "Type" - description: "Type of the device" + title: "Device Type" + description: | + Device Type options are: + router, switch, firewall, load-balancer kind: { name: "choice", choices: [ "router", "switch", "firewall", "load-balancer" ] } - required: [ "hostname", "model", "type" ] + required: [ "hostname", "model", "device_type" ] system: title: "System" - description: "System information" + description: | + Object containing System information. Has the below properties: + domain_name [required]: string + ntp_servers [required]: list of ipv4 addresses type: "object" properties: domain_name: @@ -42,7 +55,13 @@ schema: kind: { name: "ipv4" } required: [ "domain_name", "ntp_servers" ] interfaces: - title: "Interfaces" + title: "Device Interfaces" + description: | + List of device interfaces. Each interface has the below properties: + if [required]: string + desc: string + ipv4: ipv4_cidr + ipv6: ipv6_cidr type: "array" items: type: "object" diff --git a/data/example.json b/data/example.json index 888c966..0c4d37d 100644 --- a/data/example.json +++ b/data/example.json @@ -2,7 +2,7 @@ "chassis": { "hostname": "ceos-spine1", "model": "ceos", - "type": "router" + "device_type": "router" }, "system": { "domain_name": "example.com", diff --git a/data/example.schema.json b/data/example.schema.json index 92368e7..b341ba1 100644 --- a/data/example.schema.json +++ b/data/example.schema.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "title": "JSNAC Created Schema", + "title": "Example Schema", "$id": "example-schema.json", - "description": "The below schema was created by JSNAC (https://github.com/commitconfirmed/jsnac)", + "description": "Ansible host vars for my networking device. Requires the below objects:\n- chassis\n- system\n- interfaces\n", "$defs": { "ipv4": { "type": "string", @@ -46,12 +46,6 @@ "title": "Domain Name", "description": "Domain name (String) \n Format: example.com" }, - "string": { - "type": "string", - "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", - "title": "String", - "description": "Alphanumeric string with special characters (String) \n Max length: 255" - }, "hostname": { "title": "Hostname", "description": "Hostname of the device", @@ -64,16 +58,20 @@ "properties": { "chassis": { "title": "Chassis", - "description": "Chassis information", + "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": { "$ref": "#/$defs/hostname" }, "model": { - "type": "string" + "type": "string", + "title": "model", + "description": "String" }, "device_type": { + "title": "Device Type", + "description": "Device Type options are:\nrouter, switch, firewall, load-balancer\n", "enum": [ "router", "switch", @@ -81,15 +79,22 @@ "load-balancer" ] } - } + }, + "required": [ + "hostname", + "model", + "device_type" + ] }, "system": { "title": "System", - "description": "System information", + "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" + "type": "string", + "title": "domain_name", + "description": "String" }, "ntp_servers": { "title": "NTP Servers", @@ -99,20 +104,28 @@ "$ref": "#/$defs/ipv4" } } - } + }, + "required": [ + "domain_name", + "ntp_servers" + ] }, "interfaces": { - "title": "Interfaces", - "description": "Object: 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": { - "type": "string" + "type": "string", + "title": "if", + "description": "String" }, "desc": { - "type": "string" + "type": "string", + "title": "desc", + "description": "String" }, "ipv4": { "$ref": "#/$defs/ipv4_cidr" @@ -120,7 +133,10 @@ "ipv6": { "$ref": "#/$defs/ipv6_cidr" } - } + }, + "required": [ + "if" + ] } } } diff --git a/data/example.yml b/data/example.yml index e70c747..df33864 100644 --- a/data/example.yml +++ b/data/example.yml @@ -3,7 +3,7 @@ chassis: hostname: "ceos-spine1" model: "ceos" - type: "router" + device_type: "router" system: domain_name: "example.com" diff --git a/dist/jsnac-0.2.0-py3-none-any.whl b/dist/jsnac-0.2.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..7973b4f53c06ce0387b255ad6e1b792e799b3f52 GIT binary patch literal 10275 zcmaKy1yH0(v#xP>cXxLNcXxLmoWTcocZb2<9R_!IcX#*38DO~Ve`4=BJLm4Lj;!u4 zqVuWj?uz^>-%dptP%tzgARwquBL;%8f`?N0d}a7)$e(8AWNT!?XkcJzYw2uYz+mq# zLpwIDHX}2|D7`p7AtOVtJTgH)!bqb8B|jxODR&PBU4-G_FgX092m{^x=y-6Lc7=ut z`vm(8n+jMA)yc7kWL6hOFgGY)MkZ2n6!i^f@p-?Y~wxv2!&2pCN$$ z;dQeXl3s%d1oSKd1cdreFH2i9Q^(J!C-GkG)`wnPKhQb3#K>e31(L-DLt3Q*Tdg;V zMqzBKOBDA;&4lA&VjHT~es4eV#T>-*DktrOup;SDTz5UaPOZL=Qz^|_9D$Q!>qahe zcfkbqXW`qm!W3wFsT;-lJob;vFt%)HiS8;7oTGbT{93f$RyyOP9DA(FD(e3ElXdub zum4S&A#2`{m=s&i10jZQqWPvLNtQibQ$@2-& zeFN7m)$n}YIY_{wqgq%aB;=IU)`|IH{bo^NNbuE>+r_1m&`7w8hVI9r(&Nn*2rWuq z{4>txBUSWO_>%1N>s$cq1@>me%b0geEh$Q}RQ_7|X2{|)lvm9Z7AbCBJf_y>YM@+V zG(J7#Co~}@VQB?^sd2mYP`YFauzEGOEAt`X*1+|^5-hPsTs5qde$XUx0c!no3^LTL ze&y6wAmIsKge$Lj0)_g9iUxy_dwTv&$6n58w1+&HOYE+ckh3)R6AGA+A`Js$r#F*^)Q z?2yC!gy_-?vE3U#rU}IUBEyANQfKdlZVV>4&nx5gL5Sq*1yt7WFAmgQMdA+c4(!}A z#o`4cC$0qbvaAzEghqT3Ie^{|T#F1I6YQEM$ickA9lk%3L0+dSb_r}hyNDGcfa)_c z@J4)G)l(0=h{kqi9JZrhgoemyIuX}4Pd#SHeW{j$D1@gChm9X%(bh>&@XE8RuUtP3 zpT~eZ?QP1u9M(r`pBJ*oCud=mzyNd>c{E`?%r2|Gqml5l$;@8E%egDpJLQ(Vj8$9N zrw{DXGgFE6tR5_yp;n2d|0QbS$MXzzkSGhV(WW2bZj*lvzWnk(|)T2c={PhsbzR=HBI;Q{LXxl&MO(ANfS0O zTI)l-#~dUHP1MytPck|NhX)=?7=T3u@1)spdA!}Zr zIhh4U#16vcigc@e5}sJ1LZ<}mcg?yC8Y6tan5eXhYC9<#aLy->4cYav^a&^*8tz}YGwh^r$U01DcXromqqTW z7Lg}`W1cbX$o187pH#bflOG$*ps|)vYYR3dSv_HG3yX(4*I(?!$WP;_PB+z20T{`D zmLt70f0xij%t;HEm*brxxjfR$teO#GfOX?(tV_(t`juAf?#~cNtcrGCyk=#I+0y^6 zwISJK=j~8ci-R(~!On)W^~N`#fp2T!BQCIh2{ppfiwT>U?cM6# zGW;-1qKR0X22KGSiB~TR4(liiO1yfQ_nif9m1nfs61MFV2+T(Iq^CoHv|4Jr@O1hh zBL*kHYr4S`y`1*1j#@Lv76$SU6L*3j=g&d7)dKl>tNXJOT4~2$!H|(QeDkxYUg)E; zB=}9=sp+aQhmZV7bBQ#IwnM^Goz})I9%C&kELM1~rj&mVGy-FuLp-G#$F1e;{X?v- z&*wa#%kBJGI>oNvj zIihjcBOhg&R=PoC7q3au0%=DoK$Bhw6X%bDlj@zYWa)4$5+(F3h(YixM~!%;vvK@t ze4h8S2y~ZlWQfu!-}cSG{kzUyz@YaNkPm_0#`X5)!xQ1o-FRd+0PwUO+S%U!dsk!~ zep(QxM;^%sT;dnN&2C$`Jmw&4-gQ}`RHxK2IeO1n^Y9RX5pxr9zyeW~XiFKE7rD8) zS!CY;`aq}^sN*!UNJKAKGeoswcU&j?JC|yRrMVv2 z4n;y`eGn=9&CKm|chEA6_VX_M;7d*1)F_ttaHs^MK@^F5+Os@cT(jl_ts85P(-5#d zgug62)H%@m*IZLW<`}>{)^IDUW+vQLzVP+qs|4axrxYT8hg)e^TJBKBcit`GVZ!QK zwtP7bxtxhQ+Rz;sISk3o<2mhF3cR2&SvK!o&)y&Ygj03Dr*if@*vR6}t;jdu)Cx9J zY(n4cVU|$eeR=jv+Z70}US5Gez623M#@;L}m)LDJe^=36ACi#B*hs4URVLb5WE$HR z3i%Ye|60)bT2Sy>aD5$%cojSRwBPv(n0^1<`}9q=yJ%)BpWYnzjLem1_3c{&^KIRO zaFUi=b1b^mEXx*iCA>@5D9*YT#(uptlxAofMOUx5b1d)sO>XpezAUPtDlq`oczJ{g zo%-c~gPY(j7NOBt00?!0OsN7IMwNsJLx#AYYhc5MJ+#_Z;sCFGvw%Fr2QVsDZg(^y zAyeDCnm|_zPmjdp?}w05Wb-NIrnnUS=wokn%TXQ3d7h&q59)(=ac(s&F6koh^#F66 zL1i_KS4~Pf`au^6B+h*V{?>hHrWqN0Ai}O6;!KTdXLu3VRI$iWCq5+}?rCE;(Z=i| zYMvlp<2+(29@1R*6P&6@bu)~pTuvgV@VnekY+KPMM=r;xL~BIp#Z{Isje*H6XC$1`ZS<$=bn2&XEa6H`WN3dAlen#lsAmV4=p6 z?-OmaI$;P4iMLIG2WDuibqj{lQnVv)x`Cag!2?-dlf7)GCM5jg;a(?NLXrlf9xO8R zf)wIj@z!|7Vz<_NyLkK$WGp_}0kfMwl1Lvxd45M5BN8dC2kSSpI|U=iQ8lmWa`@o2 z9Lbij2GU5@SC{F_H{r-6Ah-PLOa?! z(yc(j?ty>2m&NDuj5y0-p0V3u7J>5#|&m75px3HEu{=GAQglg zB5&RPz{j$##})bn4jf&{KMFY83vw{048*mh`=INACgXSx#OcQBsu~*OE943dq;0bf zX|@yUk=S-_uh&)ujN~sJ7UBFkueiKML*V5ivn*lJr}dDbi|pwsIBs&O!9t)U@qdQy+C8Yl@epp7CU%nEab!N#E63 z9Hv4I;BO9$?cp|H0{i+1T1)Pwf5B!1OC@8g07IjhOmP1yrqX4xS}ioUL&C z*1PN-wM!0~JH7!=UtRlhW`dz8z9?`bQ$gN#MD2!@y>+QpDCQKRYO=dw)MAJjG%A^D zW{Pn5)u(+uCFxjvW@Ss+^EBDjV`v0)Ig}V6E3(I?6e=!P*)*5}kX<-%+4^ivXIg5F zXNsY6rZQXo#yRu~T*Af(E80>a!#T1D7Dr-_c3+rKlwVq*;Lgbr zq!`l#9Z2j_2U!FPkcwvrN=uo0YAqcQfc}){CvRwS5L=y9fO}oIhvnZww=99b^eQ=9 z&bXiYomG)59g~G7Tt(drT~u5pJKHynF3iIE+8Q2 z%Da_7U*2ztcscZeJ>eU>seCT7J=MGj9)LWW)Vf ziwraK&_%)--%51+(r{?qAA8wP3emfnB>ZV%<^jrYpTh5@cguzCOz*oYKg3B3u!Tp$ z0#5dpB1>`eDWRWU@Rsh`U9=>3H9&PWgijWnu~yN4RP_3)u!QOHV(@;9NDHMilV+}J z5g`2T)s9zYa7>4t*EWzyZhj!D7BYM($RTKh6S(hcZxN1^S5v8u-p0Sm`Bn2A-atBV$iqvAa1oHzCjA-5s_>Ffqz#8F#m6T|J~Wr z#_9hj3pn_xmykdJ0p)*c0$6{z{YO|ZFtM@x)CL+gb?nwT(0rC_cF@4$fON2H`LJ*N z8uU0YEwv}8L-MG=wDWC>+oB6Ian^soPVI{2+YE=h0z{0JLbqSsuI>jpHL}n&X!Q;1 zJY?3g*4Q3gg|I?;*AF)ztZ#q02=UYCJ2t`wyu$w|w7RYST8KWGax9A?U0+;ok)Qm-*R-_fU9VGJzzxNvS|NLA%Jz6G1tucYN`?>EQ^|M-k};Dz_Jk$l;T9tkUQy z>_{=>7+EXH%`}S(&9(nxT@0s+b_N<+hbp}z+H5mEHZt1v0$3c*nebFpRss+13v6?+ zR(d{Vp>eQ=ABs6NXCeO)|5Du@07<6TpSTDi_SH{|rq|+uHAoOzQ66X&4#wjFR44t| zR-uV`To@N|TbuIzXOwfuJhHQ`vVK7}G5b!qXy6hSK2$6gh}>?kP_ae5A{i__*X&8B zV65B0-N<-CC6>}XY~a9lnIvOllI9bD2etT?r zt8B=X(}m*EC_2u`f~{{FH!Z(JMFgZmya&M^jVEV@()}oXuUibcR$y7tCId5GIrg8j z`$I&&bpp_Ma|0mD(!6)8)}tgAa`~ayIPc;>gJqL>5KDwIi&ZrD;nc!d*OIMl=cwz_ zmDE~>w`@gz6Oc%V>XG+?BkieGgtUdP!;t`^&ZRrEJ3Hyy-$34W&G9yh@zYyTRm26# zMe4s?l|nX5;G2{4|)p&(3!1@~6=Rn}^-HB2+F4j-}wEM-A&<-Z;o5x#O%O;Suu zEP%@_{DgYlf$Cg}$1d#WbTQS}q-TiweIpGp$bJh+zU1{1T3F>{G}d*45k}43idbv3 ziD90pX{pWHj*Pix{?R}zqa(x%z^f|aX`1!*XJ7&|;`Z5Tn((!CU1vt3B zNT8F8bwA#Lv^)^OC{D>{yw2D848jH`w5T5-wo;ZTsPofywzRtoz=iGvQo!u$8%eMD z6tod>p19paRjer58(po|Ucipcdpt?Hn#`S)`3EB8sD@7Ka=uu^MR-y>6{W`RljCeX z?Rc5HAq>|OH5ZcU177{15O5r)S*A&1{+*zo{SQl3CC?oAS*!x zK`8}tM;nF=5KBnjVTpZRp_HJ0(!2V|>nKoj$2f$&m2Ot6IWqp{N3jI;Gh$hqRRR${ zyZ{m)Hpik=s5QTfNI_5;oUUWl_PxYxHc@%f>5Dt%eNccg5wF-{L2k3sw}m{MH@;w9 zifv5A`&^S_i?5-3gs&P@d=mm3OGKAxog#SQ7AmC>Y?pKjYi_U}7#X3lyUaD{&?)>F zX>x#h2^^Y2cr1lUA6rBL7a>9mnc~#RZLO`D=9?%b7*$?meNN~D8_$A!7VoUU#U&><{AsiG8xJSX2%3VVVB|9fs1;hx-Cv$@|9LwKHd=~z z`BbUt!GM79|Gux#Gcm9*Ffn|!baJNu6rJrDWu-*KrC~W)1ZhmFF>5t`LQeQni5w`aM!^~|?-0bW#vKq2z2YXCx+K|PpmAiR$v!saZ$7dscUSc1C%(9!7;2EMw*xG{U=<&Xw z-&o`v*zBj9TQxJZ1v~3(+}Sg}o<^p?-8<0UlkADUw7(kZ*YUKMa8)6C&g5qzJU^yGu}7M$PcbAd+=vyIKVV&(EXHtVNu zEYGhn^%{&5ppw{%E$uwJ5MYhK2ab<{G)aC@-VSSsFE5r$uL4J;XEk68R+b#P zuyO3F+MOe^U+ssjy|&3~)<&GOoZuI= zHOeula)`fCj~9ufD}~MR=F=<*cI<$jp-(#{7<)xuvA+bWs_D?@Xh5jbOoL%yf@A^2 z7^;=AXeL|fP)6K(a|WZ7NAj_w^)`FW$V){%@O;bS%L}o}XrhPA1E?F_Lv6p(qwHue zHN+LEi}hJ#g(S08MNo*1fffXZ`=0CcQK^Df<_sj$a^85l{d7t+7qMb?5<(++#hiLR-EU*ysoAcvH z+vKR~+Qb;|#U#{r&DAMS_8cWvj=5$Um);1Dqoy%3A+_Wlq;RVnA zQ;Nkg?_WnVB2O^&Mr_H=dQ7L(To%qJlg7`aGEeeDeS;mt8A`^`ST4bJ7*t7QB2n$8 zYVs4;OxL1L3T2~pXRP(4*XdmHWDcVtKiViG42pd`ET(%h713l$JbFQHG&0l?p{l2}ti?Z`T}o*y zC|U~*8ou9GFiG$HxU3A$9_1QQ*x(znOn)wg@~9faWhT3RujCLK1nE7E<8W3HN$s)C zCKc-dXYC9tsQ8>)cyd9;oQLA;*Ijbae3s$Jr<)VH3%Nw5GPU=TClr!FlLGUl-Wr#e8 zx3S#b_(k{2z+vMG?r9MNvAN0+Q7NbR3TMuC8^($7_~~FU6K=Sm^kh0j9(#N2jDaw6 z*=Ax}RxN2#M^O&Ha$UNzgL@}4qlf8};F9Ccc~*HTk}6Y~70+|D37wl93Qm;Uvj`N` z%zd-34#+lhU*myLlN=$wR1(tn4?K$Pb!u^i%UQ4pL2RJ2&Eq&QgTTq~)`*$tj*JCa zex+{F#6)$5Xae4S5+6m9n^9;Xi)^j*vu%TcR?`g8R#R0Z1$)-(k-Uts^T(-eg)=X zU%&=N2KQ)h5v!y`@lW~MT6t>V8mxVID|~=>s{TPnn`3aoN@F?x^^J{b~+$;QOg%m=N?)zAL={fq^jQ+ zx@H+G7=KJ{4b=FJM;(Hsz50Q(VV2|&8M{V&7d3WYjx~!@L=T0~^DNQ}xYzeyi!UgF zvlcVBa38B{R33wB5KWgf9;DJykY0y-TNV@THP$`)_n!Uc^f3--?^`!hnBo?_lAO}? z){?C|kH_smiTP#vt*3rj#b+XQfLfGaL*jF`h?>ZqO)SO9kM_L>C;ZLI>Uqo~j5|Ed z;WOqXzY8IM!46~k?qD2yw!1eexqMmKJ)$$;U~>qBg_Ma#PIW1i+3SB^C%ZGcBklPz zQ-;!#A=}!xRY_F+0SM~A*!;+L{&+vNox#R?ci+7J{d{U?P;?CXB2_&5*p$)_qT(?4 z^=2kNByqx*`TYe{W3%Dmw1Unzm+E3`{}mLCm11q$DRxsV)X&ZVu%0S7%!jpwMM=Y+ z!Mzg84MU}{o0vK8w=nIX^nD5J&iU30c#%rY$G%sPto14Xj{LTc->EqkDC>HZ|NUk0 z(YcxOHS2@+LGiZnVEb~kquH`#d6RF%fB53k!PV38Y_?*;7Zhpkw_?WhA%s%{q*Fs* zP!y^`yoL0Vf(dTrL*fA5v?PJ&OUz*_&8B-;Lo)$<3sEG~yUI(uLIT{u=)>*O&=RRc5_##^;-`z$I+FB}zXjc0T>|;oYtluza3cY1e z2yy%JhRP<8Za}rK+7CiQcMo+Rlwqh?51xk1Nu26a#UTQ4J1EX|^{8(c?h zGvnhbd^JnJcYw{LlP6{wJccl8e z{1!CqoWPx8UnFk(o`w8clvHh^u<(v$a z8=M{j8dp#xd?SVk47hvM3$+K0}KiGLBm^e8YTOBg19z_APfyR zBA#I}-PS*EmTs~Z4Bmdn_ovp-06r7>a=mx$_HrNserlkI52%y9#<#41A(QTtoV!|^ zZ{O+WoG0E}Crj}YTdlkgys}kH4WW-Pr?8*Abu{n<(r$MYrPIb5xH^z0l5FpC05SK9 znirQC!EgHg#rxtR=GrEYtX~c#miB?Sk2bhi~tk!pPyfH>u4IR0~iSC*Jr7K_peJTi-{;Gi&o9Z zB&G`jf$hBh#;^x=cV7XmiC+aG$roo(@e8~iG7`LBHn&DqA=smD=J#IQ6x$v+vI zR0%TMj6R*NBN`=SA3U;qq*}N2Y@J?lu?5O-KAZZsp-qR3tvf{Td`)jnfd6N1|qRa2zm%`i* zFxxX~IoF-7GZv|Gl2YramM(4&vzT~I@XkS~C)-I!A4TCIX}Hk)Uu@+XNPm=*+&-4VXjU}q;{GO8}au&cl}LV5}s8y#jc z3{W8{ptSK zoBn4D)L++sjY5B)&-wq|5cMbH&xWY~06wQn|Av1u{%()@6Y!VA-$DGPxc)r~{gaUX z4&s05uYW@R)L;LGT>6jy0r{V5?4OiBtL?ujh5n?Uk^RT>-)iqaQGeD!|BYI}{x{U$ zRnecUKeO9^v!qG>jrE^g_b2Gj%=F)&<$neJnWz3>{;$;Yf0@A2r2iJ&e)&PBA{qs2iA)AEKcCXiD?f5F1ntEE$J~aW?KM}XjIkvP4uWOy@KAl;b5Q;-VnbOw zif^Z%bmcJNx*sk){_7`xAtx@~xi#*?QEsCHbjXrp@DHb-+@dvhR`~0jZKs=#m(7OL zh6#4JrX70^yxs}!q$1#RkynTvq8(zJ1=q#ocUW0kT$!-Nm)GXCCSY)NedoJ!)KXHtTnxor|oOOQDO`tt0Q{~KV}-!{{Ypva}=BL zbnqf$V1*O8 z5tETxc1762d}9ivyt#6|kSw{gK>2t5$<=S3v5$8 z{;igpUbmY8GlW_W#8t4*X5ED*O9z4Uw#+!o5d2j~`^j50kyIl7W+wSbq4L|q90w?{ zj@-rNOlcq=zwqyFl&VuH;PZwH5T@{187Xm(;6*tQjk$PtidM#%_y{N1un@iIHuI;rnXnXLh!M5+psE}kftLn6rdfxXm>pP5lvfs_+<>c$?=@4d-7;HW5 zXKKc6$o1*t>}VOH{!W458%%g?yl(fNwUo`*Dfq*!T$v!h@Y{12c)nWU?cwY14S{S# z0BX^XJt-Xzhv(tEeL2_|JGy(ht1PW?iBX)7uM=N`J~+TPA|2fX4HWLaDk?N)M^aK| zS~#%6)b$C>(fIBNa?!x-fBb=DJ34+pzpM?p3dgI0fVe0iV8t-+`11sVF}&x*l!z95 zcvtO((9G1##>L8HFN6mH{1iGV`vzJA+r9@^{MUSUy?41@J^5Ikxrw7bjb?v@g)ayP zgxIXgnrwmG_}4E)>p;J`d8IaRA&~eW?=|ms;NZmR#a7*Dd_e$cP}%-(0mM>-MGmh8t7v~eWRKY4o-Asu z(+0s!sBWiSD0~EDs6uqSQ@X+J2ucl&RqgBZqOu|n&yzccPYAu4FWP|i>UL$CCAId)J%RC3J)IUn z#MGFIilC2{nktK1v-M%)6F*%-N3Bze z;}$O{G$4!N|LGkK(%6#;#-1BTk$H09_O{fA`@@`A3K+5>xN6){Td8+{bo zs)d>?|3767b8k_*g0t$jt~uLMKSz}#GX*>NTvTfA5Gf8;b?OsMSg@W z)ojZG8*hudZ6^hGbAPN?-rVtG?wDsp2;Y=HSP6OY{j4dBnNasO@TBYIt8+)Og(LU5K9}lNKw4~RhS}{Xqx8#GI zF<7ibs!4&xm}0GP>mv0ucP-!+e~N8TJM8kZ2qFe3fmh zCYtMPeb;7h5_CD;7C&Qk1Cezs#w%I@%ifI%Rx#kjSSuDO-e%f}&1Q<90|Jg!)dT64 zc5Az^ioSmtlpC zf#TP?qSfbMbIsE|>?eVx^d*m1`2Z0MIFIcB(IU0;?$du5`;LRmH}~xdRmBR_OrwHK z&an^`7|EQOnTUT)~Uhhb=v zdShy*NdKAdXnmnhr=j1!_QT%6>IwhRWXRd~)x?tMus3Im5f||0W_jaBu&~iZg_sNE zdiaw99^w~iqsBSXcgT{5y6x0d#p*ig1LMCZdVBfZ%5J>@-rODpuFs}IUkSN#WA|;O z*&b9liN@2T?pN<659FV3G9QtkrQ|5aJtMxT;pq*6^m=OE?NMpA9xX+TDo{37Ad`iX zQv7aFC!4gNc;&J-ht$+jT*g|Yq$_9&+Ff7m-3`ihhc&?VP$3pJZmr8u)|(Y^*VB;J zaN8BU1gPqw867%twnW`Sg6GFSFCl2nM$HQV$pHCr-?+jNd=dK?Oy*lk|AC^l>mnP7JjI&T(~&(FhiV-Y!@yY#I&gJ?2f_YbhD z5qQhc1mq_E2OOOK02<-}AN{>m&2M8qo1QxsJT^GE#p=;c?ojmqWwtQyg16`QjTBU|6--!p+zitivtgO7S zCxA#e;Qyt{euA!mP1Qgz-39($e*R)uPyPa>PgNi=;#v3|%xD^XOte4Bx8M1zE6>mG z2-u<9>t`HM4D4FDxDvboHr@L?I0(XheI#73Rrt|++XPLrq~ptM!Cv$N6sfw|Wthym zvjNMEz8?EJqIYq})X&y!^DL2X0+;d+({xPLfW0d*&WJ2$!dP1pT7;+PonY*=(*Z9# zKnsNLe=AODw+#jTnkvaRXoTyZrTzZvL@?Ef>v1{lCyY!zOz-itP!tK_VqZ>8Wfeil zrw)jd_)vF6fzH%Ji=VfZ$$iwB7N8zfyGR16R_4q-?pt26wj_!8CQs4{Mhi#IP_q0K zvGLyx;~v!G*y`_u7uhzM1q+;aCUSkpP#mcNW&?e{D$B$}#V!VN_pk03!{EZ7Ycx=a zshHDg2Fm`W>gc$8*^CUFEbLDkGgrt=_8DZ1pteI1e$4i=iEo=I7d=4WpM&xO_^;D8 zXR6Yu$%t*e^Qe%`33A(sLgBLPNJiMwomfzLelO1Cw2Dwqq0sGJ+(`a}aK4E#2AqVq zArv9i3VR|OODWyRb~|iD7knuPa$D0O2pdvWixV|6keSkI7z(*|m0A)+xmkxT1_1cW zd`L#8KhE#=%~NB&^!_}rzd+4x4t)E#jo5o88Yx>l_jc@`93E6y$iL1ST4KSe^&M}t z1D3{7u2^57R~ki2y#%d~EOa^r%LnPHm4s0^vF`SlQ%%#bS{;ZEuDda^^unOBV9Fj(m zHowTx=F|ST7>4|~fRw92!+}bDe>`B3FWr?}3XD!Jmf!lZ01wydsd+5Drkz3RnbVpj zd6#on;a5xkkZk#Z=%-^sJ!apHD$E)!cUt7ui>~4(1elO@e`lMzk%>oYB)+=Q%a6OT z|7syE5jRupDecSzigsIZY*d9d+j0@{iH3B4lpB^{hVVYG&1caug`QLegFjbGh#9bnlFQeb0yrAHHaE|GKhIlEm0evkR7CH? z&9`f;sjQX#k9da5L-@L`NSMn9b(AQQRIYkUg$*tA*LiSagy|_bp0Irmy0SgMF{MNH z6#eneN)UZBBOja80e2%a^7A|Et(6b8`)kPJHlYvXVp>oYT z%n9)fsRt9S-An#k6mdrD@p_pn;UgZd(L~%@~OWHoN8V$`bg^eu-no`vt4AXgc6M zN@zC0P1eJz1z|F4{Mb0iVX>}{jM9fa7yJCIk+)>_e7`PxYOCSxAJuiD+xJtLAqrUwd;Sg zO0C&RjG)P5KQc(4gC>u&?#s(GNc0v`>N2PgHj8NXwdaeRgv@!M__Djk`S89^aRJnK zqt+sA1)wXav@Up<+2cKSHZ?0K>!6cTC#aLp+QSC|bIzQy#{d})LUUp{b;~ia+Er+Z zh2F5F(a9%%`12?TS>I%1L3bmm&xcI8fNn$h(1Ddz%1HYm#9j4dS5k?I_) zMTfGa0+PX1A%zd51KR@CFEFi$GWFr|8_V0_h%;LOJlC(O9jb+6YNalK%#)+)I;)LZ(VGT zq+vw1k9pm}3~~;CSFo0_)WVu`Q47~7I)JSjbqy~uE%#6~#Myly-dX=whfC878voP4FNdHHopY`I zncb>mZiB1)=YVY=5n>~SC5}=#Nx>cbQf;JZ%zOqRrs`H+5lVd+R9L&(k(QlT&Pn{z z?*gHUFMJZ2p?~lCiObq{$6VC>6-U8>QpA?Kv4eW#gpBwL-89%XdnhxTc76!E}YmMemxA1fy^XK=xYL6x^;8OaQ*SXKbQ>euAQ+O)m6H7=}cmV1B=re{hP0v|AW8!;2%<R>YE46PO=uIg4a|%mG&-XN>UJAZgNW<}#QRaO3!-gVPIGb?HJ&DjSVN6H z?;fNMkhXCaB^katdKW==rlPu8_m&+@K55>`t@)W| zboBquQzv#X&<#~K>M5$v=0gm51F@KM%oyEn=4Lhq$}tP_yllR(>2UfT@AWmus?>GO z#|(7L;fCt$e@PP5?&xVY8JqDIJ(#o3<#0xLzOnT*aKJW{G&!%!9FAvlt#D zv@i9ze$2V>lA6vPbrRNL9{3be!(?U;{HiFlnXVL(J-0RO5L@+b6)R#&=L!%(Ij7p0 z2G^~LsBe%rQ@4|hw=4K?CZB zbs32O*fRa=Je&a@h5JJtUr0gq1&>4;3L8dki28^A;cvW26fIE5sxG;xoT}O4IA=Il zGhil>ZuF4X9K4z=WfpP-8xF_^@GCng;O=1E?ba|bRk1+F*7BjpxA>*6W;@^kmeAaV zX3fw|vB(EMn9tJ&no-E|1{kSd%E2W6%bkaibd8u8uV!dQW_?5v{6t!*`18*C^3ElT zx~xcEuRCYe%h z(_Yl^fCPI4C6Ir^+AdbM*SOpj{eK8;Xg=uP`}c7Z#m9L_#*!w?c8@z5v1hU5=7~l) zz6+K9^nf1ZhTKJ7h@d)>9aTQMOTuJrKFSvSn^jRoWtX>EL0lch@a@ z4G=ssyp}5+sCdvBB2+%IuoPe#A1dxar;K@MEk5Id@Lz&d;`w{Mp@MG;?POy|pK$ zwKMyCzV53OlF2KtxzNv(%ixq?WF&Hec10ZXnAyis9;Hpr0%T@y*~WNj7CG&1svNs_ zZLYVTV?&bU&}BImtcTKRI_t#;D5)p0f5|Io7=?dMJQCRS=#eDJJH~xWs=jv*FM4co zbXk{~LHnLybE;MTc- zlaQYym)EOjkzkMl@af+y5F?}w)qe>hc)%z{E-jRSrLE!$QcReywOI#zbKME_S!-v@jS zVgnKv792jxLr(_WLrIJvVP7;#De+-M)h@Y)ap(QIsXkeEU#j_?AeUa^C^-`yv0wkb z-4f;gw=(uXVU+eGR&^-KQ{x2jdPI&~X($Ag%-<@RFGUghdv&;3B%a(WyHI6??XpHe zSd+EuE4ai= zVdjKSPH+Z3<3zT9ffdHPQmKb@*cAlhQ1^qPc-84X1*s>*DJqC(4z{~(IKNSL=9rt- z@m$W|eQQovAt#)!iS>og(hqd;SwkbuUfTt_g5SeN`i{`q?jwZF?|=_8a$UwNI>qNW z4n;)ghi0IyHg&;ayjr$T=CIV7=7R zm!Xtue|dK{o>%l1kbgMwPozJ-C_`R*FN?4~R-Q9sU-$3WtHqC!(Q1KXhKLC`Wol>- zOn}W?YPn%8h*}z%%wpAF+o|D5^}zZy`Td5A@&)`9R)9<>5g$$4X&5Sf1+tpXGlST! z4tP||4oX3ctXMOJflNM@4sNTIS2!=y4dDT0XXEb%FVt+6SI-QM@vsSnJSJa90+bp; z%E~{A47_qSvU<~l>dhUwcvJ>1qOVi;2A(0AOyYUU%VrZDE|?b&jDhVII8LnKga5Vr zDcR?eZkyX}0tY&>wwVya{1UKFYJhkQO<8%LXx$*7hBBJ zs>v0Gnq~MKYSCO`6pS0=8vd?a$m;zy6?es-4U$JA_Kryo84R`aJ&noa+5y5qP6-;m2nV=1U+?7OV#_zO71EpP7BiDUsuwW# z%T$c=HOpN-uD>UwqO5=#8E)5qxnx9;gnk zrU?&I2c0yM?7!pq?NTWD{CeqP7J|pDim2XQb>);Pbo?djI}4^)|FBJ2?Ina2@ziOS zLTfCKk@-(0w9xuc84%69jxsrAo-jEO*5#RIy^2hpEQT=AYp+54W=zSSbqzwAN>_X$ zrMR3-`McjvgJgF0{)Mq4*IBj#J2gQxhJB*{0T^D4a~_re*K-eWNJSBf062uQ$NDST zv?@o~Me~tA$<=BGN{fiYDh_gQnJLJfx}?_N#O_`hi9XBpZ@61=8fX?eblow%d@mc+RR5cxsujxFKJ0Ckt%=kCD=V2wx^{1ga~la)OK|vW^bkgviO<7$ z%!I%+jt!M{=vVl8b<56!U{MUeXOjaGRYhDP%-c?2l_E0fB?74s9t?4!t!|>#g)Dpn z6leqnR(uy(E$k0Q_N-V7U2+r&-T=SGUxH5HS?pZKClLB`9xWz;x!=H@9i_C}pv?`N zo6i3_&&}SXSK)w+1H)$qD>dKgR ze2&GIC=6_5ioP48OoVpmoQKD<-GfVVH?EN=H+L(+pb+NH`S&So(-Q7^_ljL$&aI+9 z{6%nqpM6i-pIn)x>sa>9`6K8(ufA&fc9jW3Q#W$!@(0 zsR&{d-?Q7SA(f}`d3(VKdke?K3PUlyHQ6z>V86cv)Esy zun6pVtHgS>U=yx&<8o=Qz_NXINu<22a&O_J@bSc*1pqPhzd-KcG- z2W#FBsv~V;%(n#*%NrY#gGWsv_k&QrU2@%CP~YNZjd+$H43y%`cD&tnC6*#H4(Exj z%d(D^yTfe34OemZHQDyv-cpVxQ{p~*!KHAO9g-cE8$eez6&+-gpC0I!ZVvqr)jab5 zDaV&Seq82>%cneaT@U6( zKAE0`zG|kg+Y&)Dv2SHqEfT05%DlN5E&OSSmB7`Zt#bWO`2FrNEHY4N5A%2td& z4P&t|)BrWyGcK-r5lIBGLIYN2dl)bCQ%_mkTCiq&BUG-XIhG?a4E?mH?ShI59nNVc zTIkowSEhMkL{N^_BIVzhC4s4pXboLDK?b`iiC9h?NK^5!!Ots;s_!a>p3F`^=bo7{ z9j1f#z9Xp);HQd%jvO5SvJHI0qM%5Z&Gl#4REqr>nXFDBGErm^m!`p^`o2wqaaT_p z^2j*sI4IZktFv`$jx|<-bj%8T6Gbjr#rZHc*7&cx8tj`W#=`y8LshZ-@(tIygi=Pr z13@ZMi=x~Zgpapxba=_KMtcb==HKt zWZQl7ZB-Afe*xys^`9)iC++mlmjPcng_>3cnLCUQW#F5w5Vtc`^YWw~Lc_(=;{a zKcCZ0;5K2b?&cGFiNa3qI(vK$2q;{SZh{)QXadYBOTJLnmL5W6Cm|)juUAXB%Xg$Z zgP8W6F!ZUj>Wib0q=?_;z3-!S$JIn;_qTXarq*8lGFB_hsZ692XF+W*u7JUkiQ-AbBArPgqSZOK9w;Lw@a6PSWc zhcY56idTUH+=g{Kc6|m2m#;BpT@+Go5(DC#s77XAcyMX`(3~6}Xk4?TDrpV(j~iJU z!SEA8w25$xT5M1>(2Jw?&k^NfnVXU8S@WfHH(W$Y2{P zX==${5AQaq(BCEgOCF6${?7MI>0G+w-{lCU7_T3=BP(J~?SCTQW`4|%&X#V0$+pga zqI1_BTB7@ifU5OMF(jD`|LFr~pQ(x#`5(~1s*7w0$I!A*J{WCAW^fltu+M<~e}Il@ zw|7|g1JS`iXPhW063TVoJF{ScSOk@TupcFk(>nh@*P$^Yac@}Z+NddNk&?#rw?CFm z@uO>F3E^4reQDvwi&FqtZ+j6KsVW57Mn*}3S2&Ad7zpl^`CxQJ9Dd1H_?kZvB}wPZ z3YagA?Xk^QxZ~Q(<7hA5C61Q4z69vX4oTxMxG3=RGD!D@J6{F9@$y;fht+in`uzN^ zvN}(7(!~MAtI|}~Vd%F^DLpmZS>7dJp5&GHAe_i&{<9=(;5$mRBI$bxn$EKa&lvv$ zIEHRDwu`G~0K9~kIAP%*sFImcH}6a1l)f!P#<4|UAU?Q}ty#+{?E3d|kpDN}Z~=2V z1LO+}Mf=6NUP(Ge^MgZ@vbp(I3mzQMX$9?EgBxNo&l$|0Bgz|aP-SE0^` zu90gt`=xT+F6rN^IcMG^%Xe{hgKQ1S-A&c2w)rge!(XC^)D;`K42i$F8OnCU4Q->+Vtbymds zmu2F@K$BK}E;}jeLOb6-`oE(L>(zyj-2}+^v6v%BGtD&lR(qksLzQx%ub`U|1;}dD zlaPUiN`2uCyzk)0F9T#5Esvi7(FJrxTm!Gczt9?$Dw8|dPc^?2L;M02L2q6@4~dj# z+~KC(581W>{-?>{a9~xH?e-#>V~pJ??PQfVFgO+jDXk|Q#@i@*>^SrhZtM60Wube8 z#`>uL7s)^~tfHb9CA3n-H|?OkHXU1QcYHizjNn1a2?Fh(Irzh>!qs|dTUhHd3>kgd z+5IP2@-heg7qu6|;_q!Jiqeyq4Y4ZtB`VMnx}iewZ~%&5BKj7>d*&xH+Y$S<_LL|3BSDZXrM5+8c?7i7&JgQb+r6 zu>-vGrQTjvaCNQrO&xN7UH#YZWxvU)E4yT>ikPDO{EBQVLfD*442ZY?c!hUQF)*DR zoL#h?PLx^_)fJ;k73E+`)`uhY{ENVq$qgpt1L1k#w`RC{ None: - Initializes the instance of the class, setting up a logger for the class instance. + __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. @@ -20,15 +29,30 @@ class SchemaInferer: add_yaml(yaml_data: str) -> None: Parses the provided YAML data, converts it to JSON format, and stores it in the instance. - build() -> dict: - Builds a JSON schema based on the data added to the schema inferer. + 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. - infer_properties(data: dict) -> dict: - Infers the JSON schema properties for the given data. + _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: dict = {} + user_defined_kinds: ClassVar[dict] = {} def __init__(self) -> None: """ @@ -46,11 +70,11 @@ def __init__(self) -> None: self.log.addHandler(logging.NullHandler()) @classmethod - def access_user_defined_kinds(cls) -> dict: + def _view_user_defined_kinds(cls) -> dict: return cls.user_defined_kinds @classmethod - def add_user_defined_kinds(cls, kinds: dict) -> None: + 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 @@ -67,7 +91,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", json.dumps(load_json_data, indent=4)) self.data = load_json_data except json.JSONDecodeError as e: msg = "Invalid JSON data: %s", e @@ -87,23 +111,22 @@ def add_yaml(self, yaml_data: str) -> None: """ try: load_yaml_data = yaml.safe_load(yaml_data) - self.log.debug("YAML content: \n %s", 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.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 methos builds the base schema including our custom definitions for common data types. - Properties are handled by the infer_properties method to infer the properties of the schema - based on the input data provided. + 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. @@ -111,6 +134,14 @@ def build_schema(self) -> str: 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"): @@ -119,7 +150,7 @@ def build_schema(self) -> str: 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 ", 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 = { @@ -135,7 +166,21 @@ def build_schema(self) -> str: return json.dumps(schema, indent=4) def _build_definitions(self, data: dict) -> dict: - self.log.debug("Building definitions for: \n %s ", json.dumps(data, indent=4)) + """ + 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": { @@ -182,30 +227,20 @@ def _build_definitions(self, data: dict) -> dict: "title": "Domain Name", "description": "Domain name (String) \n Format: example.com", }, - # String is a default type, but in this instance we restict it to - # alphanumeric + special characters with a max length of 255. - "string": { - "type": "string", - "pattern": "^[a-zA-Z0-9!@#$%^&*()_+-\\{\\}|:;\"'<>,.?/ ]{1,255}$", - "title": "String", - "description": "Alphanumeric string with special characters (String) \n Max length: 255", - }, } # Check passed data for additional kinds and add them to the definitions for kind, kind_data in data.items(): - self.log.debug("Kind: %s ", kind) - self.log.debug("Kind Data: %s ", kind_data) - # Add the kind to the definitions + 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", "%s" % kind) - definitions[kind]["description"] = kind_data.get("description", "Custom Kind: %s" % 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}) + 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" @@ -214,53 +249,90 @@ def _build_definitions(self, data: dict) -> dict: 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)) + 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)) + self.log.debug("Building properties for: \n%s ", json.dumps(data, indent=4)) properties: dict = {} - for object, object_data in data.items(): - self.log.debug("Object: %s ", object) - self.log.debug("Object Data: %s ", object_data) - # Think of a way to have better defaults for title and description - # Also, inner properties aren't getting a default description for some reason? - properties[object] = {} - properties[object]["title"] = object_data.get("title", "%s" % object) - properties[object]["description"] = object_data.get("description", "Object: %s" % object) - # Check if our object has a type, if so we will continue to dig depper until kinds are found - if "type" in object_data: - match object_data.get("type"): - case "object": - properties[object]["type"] = "object" - if "properties" in object_data: - properties[object]["properties"] = self._build_properties(object_data["properties"]) - case "array": - properties[object]["type"] = "array" - # Check if the array contains an object type, if so we will build the properties for it - if "type" in object_data["items"]: - properties[object]["items"] = {} - properties[object]["items"]["type"] = object_data["items"]["type"] - properties[object]["items"]["properties"] = self._build_properties( - object_data["items"]["properties"] - ) - # Otherwise its just a list of a specific kind - elif "kind" in object_data["items"]: - properties[object]["items"] = self._build_kinds(object_data["items"]["kind"]) - case _: - self.log.error( - "Invalid type (%s) for object (%s), defaulting to Null", object_data.get("type"), object - ) - properties[object]["type"] = "null" - # We've reached an object with a kind key, we can now build the reference based on the kind - elif "kind" in object_data: - kind = self._build_kinds(object_data["kind"]) - properties[object] = kind - self.log.debug("Returned Properties: \n %s ", json.dumps(properties, indent=4)) + 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_kinds(self, data: dict) -> dict: # noqa: C901 PLR0912 - self.log.debug("Building kinds for: \n %s ", json.dumps(data, indent=4)) + 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 @@ -280,27 +352,34 @@ def _build_kinds(self, data: dict) -> dict: # noqa: C901 PLR0912 kind["$ref"] = "#/$defs/ipv6_prefix" case "domain": kind["$ref"] = "#/$defs/domain" - # For the choice kind, read the choices key + # 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 key") + 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.access_user_defined_kinds(): + 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 From 22df8a89e8a63019eea775d16d2e726203b02507 Mon Sep 17 00:00:00 2001 From: Andrew Jones Date: Sat, 30 Nov 2024 23:50:14 +1100 Subject: [PATCH 3/3] 0.2.0 release --- dist/jsnac-0.2.0-py3-none-any.whl | Bin 10275 -> 10695 bytes dist/jsnac-0.2.0.tar.gz | Bin 11340 -> 12128 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/dist/jsnac-0.2.0-py3-none-any.whl b/dist/jsnac-0.2.0-py3-none-any.whl index 7973b4f53c06ce0387b255ad6e1b792e799b3f52..98123724343e3f6e0876b9ed8ae5867f5cd1731f 100644 GIT binary patch delta 3147 zcmV-R47Bs3P{&iS;u#8kA2@p62><}>80MLqhz5m{40Z=6Uk~q`r^h-07gaQ_eeR=i;`&L?! z2rK$ON^Me=PiSv2j!wl&PUzC)B968jqru)_6rGi;mC&0By*Yb#N@wv>t^{@GO7GIC zw0~=*uSgg=QVAHmDAO$QgvJpXnrpS9pAO%iM6cCK_6xB9-O}2^OoqcnYM13~5UbS? ze^;rERX$JkN+v@u%Sjr`+~CC93Q2=pvhjI}ObZkDRd zl3k~TpiEiT-7eYuYyhvA`+uGZWE}9XOns(G4O4^sTqD7eQCaDX$83u-%Y0si(~JT~ zN!KDvleTwRUWhY*Ki3k7=`kU$sdzcE9SfDQNldY!68o`mMBYCst_miWxVkJTM&MY^ zDF*qxwzh#CwYNDNlCAU6Fqvr911D4^D>nZC8GNMkFWjg-N9Z7Ep??&V)dH}I9ase& z9S@IQ22;-%8-|?R;4wqEvqZ3|6H$5-0aL=p%K^s3rkevR*1U z8Z@yqhGrXwjUZW#_l7#9Q_+cRHb_@rz(-gh%K#{KD5mXDv=WzcdZ8}NVai!QRLfn~ z5(ztjxTk8pMblCha(@o5Ls3=+mnis13-DSYYl|zenn~DJx+5G-PLB7P*vyo=>QyFj ztG6Syj^au*2BQzk53kP7>F1KIY?Wtl6P=(vmzi8i3!*JlC%Z0Lz0j$g)2lB6j3vAP zVZjxEPvdRIfjV}2EwpJwuMaogfojO2lPLQ9`7;-%C|+`vGk=qa+{Azfx+nScABomy9MI$D)h>P{4yje*Y0DnWWo^J(BFy}+E_`E+iJsX^8S$R>0_ zo$-jv;$YnAZNK+A^a+$i5dvH4xd4cmZ=cdR7@jCq%tU-O>NuhV)ex&6^F(TRiK=bC zn-7sD2_JG8q?KjL7z|Nfh*g*f}311|2quHiIJ6Mf^%{@2g! zZ_l_8{wtDghGOpeATA*&H$|beb@`P8ZCiNJd;4fWTqbp|j$bZVsoN}6=Vjjsq? ziL`%6Bt4Qg9?&81j@3E%q04sB9CE7fg3x0?=zl7-Q*$fJ*I}I|bwUmj3@(kzJ6{iZ zYN|1ze>uC|qH~5=ZK`_B<_P^ARZk-)trM@(+WVkirIPT=2T}M@!!nAT0bAv&m3v-? z)jDBsh2HK}M)R_@(o`YP`s&=TX}OSJu=tM)(f{XR|L>#zpZlMF8Ta;|{QA4^%z|y} zQGbBlJ7)oIA}kUBn=2gRE=Otq#6Z(2nr3UnSH-6?1zl9Y`83%sHZx|6z=zT86xky3 z*^s(E6ZXjG!5&pvut%*NxceLC{EFZ=&H9yh!UK#S&2^@{1LOWrgPN2NWscKk`gov$ z{`=*Jw%Nn+*jf?v&VKySFpclDS~V+}=zq+u-G$$K;p>k)5WTmn-~I4xl)DyDhL)Yf znKL|zBteET()=$JQ2Abk`w?+1(u_w~{PbW4KDtPnW5ov#1pPbqqrv-$zaG=k$+2rw z@Q7VTkLf)OV%-gHl(a%PGpXZB{Uwk0UfeM|aN~*QysDeXxhOO1f1#jyxf%`6ZpGA( z(1uk{mw2X_tPTLrVP!mqwy9xXcKf1DW1WJ5^fIYt>fPROFxBlk9apRaN|#=x%Uz` znnFIyyE}L5y3?Z_y|c@xVSnS+3B}!b;1*q6cXw;42PnByeqnT(swFbF1I}{J&k52^ zY_pB?89$=J(+RH`I&6vZX%l}MU+KB951?QKp zUjjqiJ$1ElI;DdH+L=yWwVdvF^}Q!uDEu2xO9KQH000080000X0HatU0R(!qcm|VP zBPxHnlAAyP1kilH1+;*;6gc1%>>_R?Ey)z10Wq8!23=oo*V=L^urg;Iz3Of>^;z)_ zK;72_fW>&Drenql;t!JPb-zQ0)$~P#vt@dj?HY=GsKo9offF>vc;4_=J8Le~N_<1# zPBCEkFN~LTBN&lvsZmCQ*zR_;@S(vufMI{r3GQb6*rn^`F`CVUVxFzKFCl({?4@#s zY3Xj3p>c_kD>LR+I{`KkMoo?+*Dq8Z>s~vC1;t7{^T1|Ox6}V>_8a@oplItS?fA*~ z7!g)5Z*o->Tk}4sBFb7H=Np3Td$1f35@{5A}@}8t;9z-)A+OcAeuIre`fMOYO^+Mekv7#%n@?dei z9~t$4vfCxuM;$u`pS_oivfu?%wy#cEYN4iww{sd$63fdk*qvn<&cmGi6))-%GBpdj zcfmgKrNcWj?c*Ibr>b=JdEOMYonAdEN`586p5Pl8hq@=zf?Ua8+{sBJB+Hg;5r4NG z?09Ofas$m27@ORwghZC$U}XM83ET@jG&C>NDeCPfvp^-P0SbK|IC|a*008V8lk_HB z0`4P|@EI7BY9}uOdnA+a85olbBM>Z5O928D0~7!N00;m803k?vw0H)*0RRB{0ssIU l00000000000001_0q!J|@EI7Bej^5x4JZ%>mikCU!dQda?>cLLzLEU;$8y*3w%W0Mi=z^$(xJl7hTA1C1${7&Q*5Q>&=wQ>PejFqKAKla)~ad z$`mr~1zRpuB1?-CZ_clxliFq4jOe6Hja<-cT}s<5cA-nr#uBUalxN8TGfGXgrx z8C{5SR>Mig2UYy_^n39z?fZ`mzW>N@6PPn)-H58?QWQD0UmPFBAVBB&Kk#4wFoLuj znYix94;Lf)AS-Q^(`MQE>$yCWHZiJl;8yokmrjO~nv*MC1; zpXsD701%9(OZ;EkPwkZ|BSq>mnd-#G+RS=Yu+0WVy@^O?viH+G@c1|vPFi=kIqp&M zwh*Ozvg`$5?EDO_t9QTQ?`5r=3|7W= zK0Az}C*%>)qa{YbKB0GTZLZ`Zihr&$Nrkx4M!6;F=>}e=bY4!4u+G$ptBoX4J6(i$ zDoZJfhg9h+Gp3jLcBm_>Cb^_vv~~2WFiPN;?W~2K$vMbR+&#!?n)s*)dacSEF(mK( ztTsW#0>SB!Hgr=%n80qHBSpLgGrfqVu4y4k$FfXa0u=Z`VkT5+{d!e9SbvERQEDX1 z5K^7OA%8d%b4j_nmHCnuhyWM`A?y)N75v~JcuXcz7M99MJp;lmE6t`g%bCopwR~@< znALLaUWr!Y#UUTf&(sGZj-qoYL^x9^azcoJXS))twOP5%be*S%-U~swcI>;$IQ$%d z;F$lO2y`6CUxoTo*9M^m{C~=z!I9N@?Ssc+t2)m^Uq#ZahD1qok*jn!yKFDaiILBR z1YC1 z6*SWtaWMi6OFXYdV_e9}glS|cxL6kxbOyEo;rB+#5KYiexFn0YtblI13JFzMhFVor zxtcCR1{awICsw|%Xv6_n5hsXMYL%0XsF+837Rmdb_wqtS1tIl>P|UoXdox_sH!;>)XyM8h-U* zJ#T8=NrZxtNJ=Q(53eq-=#QEM>~s)tpTodj$y^rF0kkIc=+L*cmqy7cz4{^`SRw&1 zmVCTavhGW8$BpO0*sa)wqU0U8hF&|0qR*c{^A#2)8Q+C=6n~N5Fa3zqN~E@3Td7hG zTG%PjRP$z;e>;C6wT!NvG_5GIOABXNMvCsK%5ft`21~j7^Rd!ZV~eH?VvXpUT7y2{ zn(?63S$_{(^a)6!2z4*bRDd`vw@YdYffrg=6Or8ZTOO3+vc&4gGL?o)Z?zqERW*M$ z+GyzydQXSzoqr?j4Ez1TXgCss(P+>c4FB2MD0;1^_UF3*lcK3!x#@tNrs_kn@vsv5#zxX^4_Fbs zwlb!b_He=E=svenR-rM-FW@`3iO^@K{-g)`3&$6K;C~k9d%JnIQFb`k;pQx7+%$26 z3kOS$5tw$QOX9=$+a&X0^CQ4G(5Vu*n@qX2-mD<5|M7&p7Ty1zbpQS7ZqOMV{dwK* z{`l!{U!~^*`AMeH_O_P80#k9tWbn|UhLg;HG5z@iSKYMr>8mV%peXF#F1oL$Ad;!%cReo^6 zKqR^MqVpy4u|9LRj{u$T$v*x0)%J9^C;OfMEA>$FwdCLJ$-K8|r`O#*$brY~?QwT| za_Z5m%zpzA(*97J&*cVlvHY0Kl*l@Cdqs$saT}^JZTh z7yW@sqYYLu^ehb?PjY?O;vNGz99(gNad~AH@>}ukq%Fb!!bHWi)?K06`X**k2C3SC=atvUz*0Lv4Tm?&HVN+Of+85oleDK7%oB9rhL7?ZCg z5R+#q5FFm-&jh>y008;|000~S0000000000005)`N+Xl-85onRDHaAmB>(^b0AG+l AT>t<8 diff --git a/dist/jsnac-0.2.0.tar.gz b/dist/jsnac-0.2.0.tar.gz index 973829cec5170f3295f59763737a2b19456f6c7b..8475346b7327e03d20c5aaabf2e36021bbe12b1a 100644 GIT binary patch literal 12128 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 literal 11340 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