diff --git a/.flake8 b/.flake8 deleted file mode 100644 index ece79a8..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -ignore = E501,W503,E203,F401 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 322bdc2..6825730 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,19 +1,22 @@ -name: hier_config build and test +name: hier_config build and test on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, 3.11] - + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + # - "3.13" ModuleNotFoundError: No module named 'pipes' steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -21,11 +24,11 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install poetry - uses: snok/install-poetry@v1.2.1 + uses: snok/install-poetry@v1 + with: + version: 1.5.1 - name: Run tests run: | poetry install --no-interaction --no-root - poetry run mypy hier_config - poetry run pylint --rcfile=pylintrc hier_config - poetry run flake8 . - poetry run pytest + poetry run python scripts/build.py lint + poetry run python scripts/build.py pytest --coverage diff --git a/.github/workflows/deploy-pypi.yml b/.github/workflows/deploy-pypi.yml index a0eda58..f35ba9d 100644 --- a/.github/workflows/deploy-pypi.yml +++ b/.github/workflows/deploy-pypi.yml @@ -6,20 +6,20 @@ on: jobs: deploy: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.9' - name: Install poetry - uses: snok/install-poetry@v1.1.2 - - name: Build and publish to Pypi + uses: snok/install-poetry@v1 + with: + version: 1.5.1 + - name: Build and publish to PyPI env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + TWINE_API_KEY: ${{ secrets.PYPI_API_TOKEN }} run: | - poetry publish --build -u ${TWINE_USERNAME} -p ${TWINE_PASSWORD} + poetry config pypi-token.pypi $TWINE_API_KEY + poetry publish --build diff --git a/.readthedocs.yml b/.readthedocs.yml index 3021824..f847129 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,7 +1,10 @@ version: 2 +build: + os: ubuntu-lts-latest + tools: + python: "3.10" mkdocs: configuration: mkdocs.yml python: - version: 3.9 install: - requirements: docs/requirements.txt diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..3c99231 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,9 @@ +extends: default + +rules: + indentation: + spaces: 2 + indent-sequences: consistent + document-start: + present: false + line-length: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5714782..683840b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,16 +27,16 @@ Create a branch git checkout -b YOUR-BRANCH ``` -Make sure tests pass: +Make sure linters, type-checkers, and tests pass: ``` -pytest +python scripts/build.py lint-and-test ``` -Make your change. Add tests for your change. Make the tests pass: +Make your change. Add tests for your change. Make the linters, type-checkers, and tests pass: ``` -pytest +python scripts/build.py lint-and-test ``` Push to your fork and submit a pull request. diff --git a/README.md b/README.md index 6b76945..f6dd602 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,26 @@ -[![Build Status](https://travis-ci.org/netdevops/hier_config.svg?branch=master)](https://travis-ci.org/netdevops/hier_config) - # Hierarchical Configuration -Hierarchical Configuration is a python library that is able to take a running configuration of a network device, compare it to its intended configuration, and build the remediation steps necessary bring a device into spec with its intended configuration. +Hierarchical Configuration, also known as `hier_config`, is a Python library designed to query and compare network devices configurations. Among other capabilities, it can compare the running config to an intended configuration to determine the commands necessary to bring a device into compliance with its intended configuration. -Hierarchical Configuraiton has been used extensively on: +Hierarchical Configuration has been used extensively on: - [x] Cisco IOS - [x] Cisco IOSXR - [x] Cisco NXOS - [x] Arista EOS +- [x] HP Procurve (Aruba AOSS) -However, any NOS that utilizes a CLI syntax that is structured in a similar fasion to IOS should work mostly out of the box. - -NOS's that utilize a `set` based CLI syntax has been added as experimental functionality. OS's that utilize this syntax are: +In addition to the Cisco-style syntax, hier_config offers experimental support for Juniper-style configurations using set and delete commands. This allows users to remediate Junos configurations in native syntax. However, please note that Juniper syntax support is still in an experimental phase and has not been tested extensively. Use with caution in production environments. - [x] Juniper JunOS - [x] VyOS -The code documentation can be found at: https://hier-config.readthedocs.io/ +Hier Config is compatible with any NOS that utilizes a structured CLI syntax similar to Cisco IOS or Junos OS. + +The code documentation can be found at: https://hier-config.readthedocs.io/en/latest/ Installation ============ -Hierarchical Configuration can be installed directly from github or with pip: - -### Github -1. [Install Poetry](https://python-poetry.org/docs/#installation) -2. Clone the Repository: `git clone git@github.com:netdevops/hier_config.git` -3. Install `hier_config`: `cd hier_config; poetry install` - ### Pip -6. Install from PyPi: `pip install hier-config` - -Basic Usage Example -=================== - -In the below example, we create a hier_config host object, load a running config and a generated config into the host object, load the remediation, and print out the remediation lines to bring a device into spec. - -``` ->>> from hier_config import Host ->>> import yaml ->>> ->>> options = yaml.load(open('./tests/fixtures/options_ios.yml'), Loader=yaml.SafeLoader) ->>> host = Host('example.rtr', 'ios', options) ->>> ->>> # Build Hierarchical Configuration object for the Running Config ->>> host.load_running_config_from_file("./tests/fixtures/running_config.conf") -HConfig(host=Host(hostname=example.rtr)) ->>> ->>> # Build Hierarchical Configuration object for the Generated Config ->>> host.load_generated_config_from_file("./tests/fixtures/generated_config.conf") -HConfig(host=Host(hostname=example.rtr)) ->>> ->>> # Build and Print the all lines of the remediation config ->>> ->>> print(host.remediation_config_filtered_text({}, {})) -vlan 3 - name switch_mgmt_10.0.3.0/24 -vlan 4 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - no shutdown - mtu 9000 - ip access-group TEST in -interface Vlan3 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown -``` - -The files in the example can be seen in the `tests/fixtures` folder. +Install from PyPi: `pip install hier-config` diff --git a/docs/advanced-topics.md b/docs/advanced-topics.md index c9bfae3..7b92e7b 100644 --- a/docs/advanced-topics.md +++ b/docs/advanced-topics.md @@ -1,21 +1,21 @@ # Advanced Topics -## Lineage Rules +## MatchRules -Lineage rules are rules that are written in YAML. They allow users to seek out very specific sections of configurations or even seek out very generalized lines within a configuration. For example, suppose you just wanted to seek out interface descriptions. Your lineage rule would look like: +MatchRules, written in YAML, help users identify either highly specific sections or more generalized lines within a configuration. For instance, if you want to target interface descriptions, you could set up MatchRules as follows: ```yaml -- lineage: +- match_rules: - startswith: interface - startswith: description ``` -In the above example, a start of a lineage is defined with the **- lineage:** syntax. From there the interface is defined with the **- startswith: interface** syntax under the **- lineage:** umbrella. This tells hier_config to search for any configuration that starts with the string **interface** as the parent of a configuration line. When it finds an **interface** parent, it then looks at any child configuration line of the interface that starts with the string **description**. +This setup directs hier_config to search for configuration lines that begin with `interface` and, under each interface, locate lines that start with `description`​​. -With lineage rules, you can get as deep into the children or as shallow as you need. Suppose you want to inspect the existence or absence of http, ssh, snmp, and logging within a configuration. This can be done with a single lineage rule, like so: +With MatchRules, you can specify the level of detail needed, whether focusing on general configuration lines or diving into specific subsections. For example, to check for the presence or absence of HTTP, SSH, SNMP, and logging commands in a configuration, you could use a single rule as follows: ```yaml -- lineage: +- match_rules: - startswith: - ip ssh - no ip ssh @@ -27,29 +27,33 @@ With lineage rules, you can get as deep into the children or as shallow as you n - no logging ``` -Or suppose, you want to inspect whether BGP IPv4 AFIs are activated. You can do this with the following: +This rule will look for configuration lines that start with any of the listed keywords​. + +To check whether BGP IPv4 AFIs (Address Family Identifiers) are activated, you can use the following rule: ```yaml -- lineage: +- match_rules: - startswith: router bgp - startswith: address-family ipv4 - endswith: activate ``` -In the above example, I utilized a different keyword to look for activated BGP neighbors. The keywords that can be utilized within lineage rules are: +In this example, the `activate` keyword is used to identify active BGP neighbors. Available keywords for MatchRules include: - startswith - endswith - contains - equals -- re_search +- re_search (for regular expressions) + +These options allow you to target configuration lines with precision based on the desired pattern​. -You can also put all of the above examples together in the same set of lineage rules like so: +You can also combine the previous examples into a single set of MatchRules, like this: ```yaml -- lineage: +- match_rules: - startswith: interface - startswith: description -- lineage: +- match_rules: - startswith: - ip ssh - no ip ssh @@ -59,27 +63,27 @@ You can also put all of the above examples together in the same set of lineage r - no snmp-server - logging - no logging -- lineage: +- match_rules: - startswith: router bgp - startswith: address-family ipv4 - endswith: activate ``` -When hier_config consumes the lineage rules, it consumes them as a list of lineage rules and processes them individually. +When `hier_config` processes MatchRules, it treats each as a separate rule, evaluating them individually to match the specified configuration patterns​. ## Working with Tags -With a firm understanding of lineage rules, more complex use cases become available within hier_config. A powerful use case is the ability to tag specific sections of configuration and only display remediations based on those tags. This becomes very handy when you're attempting to execute a maintenance that only targets low risk configuration changes or isolate the more risky configuration changes to scrutinize their execution during a maintenance. +With a solid understanding of MatchRules, you can unlock more advanced capabilities in `hier_config`, such as tagging specific configuration sections to control remediation output based on tags. This feature is particularly useful during maintenance, allowing you to focus on low-risk changes or isolate high-risk changes for detailed inspection. -Tagging expands on the use of the lineage rules by creating an **add_tags** keyword to a lineage rule. +Tagging builds on MatchRules by adding the **apply_tags** keyword to target specific configurations. -Suppose you had a running configuration that had an ntp configuration that looked like: +For example, suppose your running configuration contains an NTP server setup like this: ```text ntp server 192.0.2.1 prefer version 2 ``` -However, your intended configuration utilized a publicly available NTP server on the internet: +But your intended configuration uses publicly available NTP servers: ```text ip name-server 1.1.1.1 @@ -87,290 +91,66 @@ ip name-server 8.8.8.8 ntp server time.nist.gov ``` -You could create a lineage rule that targeted that specific remediation like this: +You can create a MatchRule to tag this specific remediation with "ntp" as follows: ```yaml -- lineage: +- match_rules: - startswith: - ip name-server - no ip name-server - ntp - no ntp - add_tags: ntp + apply_tags: [ntp] ``` -Now we can modify the script above to load the tags and create a remediation of the said tags: +With the tags loaded, you can create a targeted remediation based on those tags as follows: ```python #!/usr/bin/env python3 -# Import the hier_config Host library -from hier_config import Host - -# Create a hier_config Host object -host = Host(hostname="aggr-example.rtr", os="ios") - -# Load the tagged lineage rules -host.load_tags_from_file("./tests/fixtures/tags_ios.yml") - -# Load a running configuration from a file -host.load_running_config_from_file("./tests/fixtures/running_config.conf") - -# Load an intended configuration from a file -host.load_generated_config_from_file("./tests/fixtures/generated_config.conf") - -# Create the remediation steps -host.remediation_config() - -# Display the remediation steps for only the "ntp" tags -print(host.remediation_config_filtered_text(include_tags={"ntp"}, exclude_tags={})) -``` - -In the script, we made two changes. The first change is to load the tagged lineage rules: -`host.load_tags_from_file("./tests/fixtures/tags_ios.yml")`. -And the second is to filter the remediation steps by only including steps that are tagged with **ntp** via the **include_tags** argument. - -The remediation looks like: - -```text -no ntp server 192.0.2.1 prefer version 2 -ip name-server 1.1.1.1 -ip name-server 8.8.8.8 -ntp server time.nist.gov -``` - -## hier_config Options - -There are a number of options that can be loaded into hier_config to make it better conform to the nuances of your network device. By default, hier_config loads a set of [sane defaults](https://github.com/netdevops/hier_config/blob/master/hier_config/options.py) for Cisco IOS, IOS XE, IOS XR, NX-OS, and Arista EOS. - -Below are the configuration options available for manipulation. - -```python -base_options: dict = { - "style": None, - "negation": "no", - "syntax_style": "cisco", - "sectional_overwrite": [], - "sectional_overwrite_no_negate": [], - "ordering": [], - "indent_adjust": [], - "parent_allows_duplicate_child": [], - "sectional_exiting": [], - "full_text_sub": [], - "per_line_sub": [], - "idempotent_commands_blacklist": [], - "idempotent_commands": [], - "negation_default_when": [], - "negation_negate_with": [], -} -``` - -The default options can be completely overwritten and loaded from a yaml file, or individual components of the options can be manipulated to provide the functionality that is desired. - -Here is an example of manipulating the built-in options. - -```python -# Import the hier_config Host library -from hier_config import Host - -# Create a hier_config Host object -host = Host(hostname="aggr-example.rtr", os="ios") - -# Create an NTP negation ordered lineage rule -ordered_negate_ntp = {"lineage": [{"startswith": ["no ntp"], "order": 700}]} - -# Update the hier_config options "ordering" key. -host.hconfig_options["ordering"].append(ordered_negate_ntp) -``` - -Here is an example of completely overwriting the default options and loading in your own. - -```python -# import YAML +# Import necessary libraries import yaml +from pydantic import TypeAdapter +from hier_config import WorkflowRemediation, get_hconfig +from hier_config.models import Platform, TagRule -# Import the hier_config Host library -from hier_config import Host - -# Load the hier_config options into memory -with open("./tests/fixtures/options_ios.yml") as f: - options = yaml.load(f.read(), Loader=yaml.SafeLoader) - -# Create a hier_config Host object -host = Host(hostame="aggr-example.rtr", os="ios", hconfig_options=options) -``` - -In the following sections, I'll cover the available options. - -#### style - -The **style** defines the os family. Such as **ios**, **iosxr**, etc. - -Example: +# Load the running and generated configurations from files +with open("./tests/fixtures/running_config.conf") as f: + running_config = f.read() -```yaml -style: ios -``` +with open("./tests/fixtures/generated_config.conf") as f: + generated_config = f.read() -#### negation +# Load tag rules from a file +with open("./tests/fixtures/tag_rules_ios.yml") as f: + tags = yaml.safe_load(f) -The **negation** defines how an os handles negation. The default is **no**. However, in some circumstances, the negation method is different. Comware, for instance uses **undo** as the negation method and set based syntax uses **delete** for negation. +# Validate and format tags using the TagRule model +tag_rules = TypeAdapter(tuple[TagRule, ...]).validate_python(tags) -```yaml -negation: no -``` +# Initialize a WorkflowRemediation object with the running and intended configurations +wfr = WorkflowRemediation( + running_config=get_hconfig(Platform.CISCO_IOS, running_config), + generated_config=get_hconfig(Platform.CISCO_IOS, generated_config) +) -#### syntax_style - -**syntax_style** is used when using a configuration syntax that is different than Cisco ios-style configuration syntax. The only non-Cisco based syntax supported is **juniper**. Calling the juniper syntax style will call additional parsing methods when loading configurations into memory. - -Default: -```yaml -syntax_style: cisco -``` +# Apply the tag rules to filter remediation steps by tags +wfr.apply_remediation_tag_rules(tag_rules) -Juniper: -```yaml -syntax_style: juniper +# Display remediation steps filtered to include only the "ntp" tag +print(wfr.remediation_config_filtered_text(include_tags={"ntp"}, exclude_tags={})) ``` -#### sectional_overwrite_no_negate - -The sectional overwrite with no negate hier_config option will completely overwrite sections of configuration without negating them. This option is often used with the RPL sections of IOS XR devices that require that the entire RPL be re-created when making modifications to them, rather than editing individual lines within the RPL. - -An example of sectional overwrite with no negate is: - -```yaml -sectional_overwrite_no_negate: -- lineage: - - startswith: as-path-set -- lineage: - - startswith: prefix-set -- lineage: - - startswith: route-policy -- lineage: - - startswith: extcommunity-set -- lineage: - - startswith: community-set -``` - -#### sectional_overwrite - -Sectional overwrite is just like sectional overwrite with no negate, except that hier_config will negate a section of configuration and then completely re-create it. - -#### ordering - -Ordering is one of the most useful hier_config options. This allows you to use lineage rules to define the order in which remediation steps are presented to the user. For the ntp example above, the ntp server was negated (`no ntp server 192.0.2.1`) before the new ntp server was added. In most cases, this wouldn't be advantageous. Thus, ordering can be used to define the proper order to execute commands. - -All commands are assigned a default order weight of 500, with a usable order weight of 1 - 999. The smaller the weight value, the higher on the list of steps a command is to be executed. The larger the weight value, the lower on the list of steps a command is to be executed. To create an order in which new ntp servers are added before old ntp servers are removed, you can create an order lineage that weights the negation to the bottom. - -Example: - -```yaml -ordering: -- lineage: - - startswith: no ntp - order: 700 -``` - -With the above order lineage applied, the output of the above ntp example would look like: +The resulting remediation output appears as follows: ```text +no ntp server 192.0.2.1 prefer version 2 ip name-server 1.1.1.1 ip name-server 8.8.8.8 ntp server time.nist.gov -no ntp server 192.0.2.1 prefer version 2 -``` - -#### indent_adjust - -coming soon... - -#### parent_allows_duplicate_child - -coming soon... - -#### sectional_exiting - -Sectional exiting features configuration sections that have a configuration syntax that defines the end of a configuration section. Examples of this are RPL (route policy language) configurations in IOS XR or peer policy and peer session configurations in IOS BGP sections. The sectional exiting configuration allows you to define the configuration syntax so that hier_config can render a remediation that properly exits those configurations. - -An example of sectional exiting is: - -```yaml -sectional_exiting: -- lineage: - - startswith: router bgp - - startswith: template peer-policy - exit_text: exit-peer-policy -- lineage: - - startswith: router bgp - - startswith: template peer-session - exit_text: exit-peer-session ``` -#### full_text_sub - -Full text sub allows for substitutions of a multi-line string. Regular expressions are commonly used and allowed in this section. An example of this would be: - -```yaml -full_text_sub: -- search: "banner\s(exec|motd)\s(\S)\n(.*\n){1,}(\2)" - replace: "" -``` - -This example simply searches for a banner message in the configuration and replaces it with an empty string. - -#### per_line_sub - -Per line sub allows for substitutions of individual lines. This is commonly used to remove artifacts from a running configuration that don't provide any value when creating remediation steps. - -An example is removing lines such as: - -```text -Building configuration... - -Current configuration : 3781 bytes -``` - -Per line sub can be used to remove those lines: - -```yaml -per_line_sub: -- search: "Building configuration.*" - replace: "" -- search: "Current configuration.*" - replace: "" -``` - -#### idempotent_commands_blacklist - -coming soon... - -#### idempotent_commands - -Idempotent commands are commands that can just be overwritten and don't need negation. Lineage rules can be created to define those commands that are idempotent. - -An example of idempotent commands are: - -```yaml -idempotent_commands: -- lineage: - - startswith: vlan - - startswith: name -- lineage: - - startswith: interface - - startswith: description -``` - -The lineage rules above specify that defining a vlan name and updating an interface description are both idempotent commands. - -#### negation_default_when - -coming soon... - -#### negation_default_with - -coming soon... +## Drivers ## Custom hier_config Workflows diff --git a/docs/experimental-features.md b/docs/experimental-features.md deleted file mode 100644 index fd3723f..0000000 --- a/docs/experimental-features.md +++ /dev/null @@ -1,342 +0,0 @@ -# Experimental Features - -Experimental features are those features that work, but haven't been thoroughly tested enough to feel confident to use in production. - -## Rollback Configuration - -Starting in version 2.0.2, a featured called rollback configuraiton was introduced. The rollback configuration is exactly what it sounds like. It renders a rollback configuration in the event that a remediation causes a hiccup when being deployed. The rollback configuration does the inverse on a remediation. Instead of a remediation being renedered based upon the generated config, a rollback remediation is rendered from the generated config based upon the running configuration. - -A rollback configuration can be rendered once the running and generated configurations are loaded. Below is an example. - -```bash ->>> from hier_config import Host ->>> host = Host(hostname="aggr-example.rtr", os="ios") ->>> host.load_running_config_from_file("./tests/fixtures/running_config.conf") ->>> host.load_generated_config_from_file("./tests/fixtures/generated_config.conf") ->>> rollback = host.rollback_config() ->>> for line in rollback.all_children_sorted(): -... print(line.cisco_style_text()) -... -no vlan 4 -no interface Vlan4 -vlan 3 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - no mtu 9000 - no ip access-group TEST in - shutdown -interface Vlan3 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 ->>> -``` - - -## Unified diff - -Starting in version 2.1.0, a featured called unified diff was introduced. It provides a similar output to difflib.unified_diff() but is aware of out of order lines and the parent child relationships present in the hier_config model of the configurations being diffed. - -This feature is useful in cases where you need to compare the differences of two network device configurations. Such as comparing the configs of redundant device pairs. Or, comparing running and intended configs. - -In its current state, this algorithm does not consider duplicate child differences. e.g. two instances `endif` in an IOS-XR route-policy. It also does not respect the order of commands where it may count, such as in ACLs. In the case of ACLs, they should contain sequence numbers if order is important. - -```bash -In [1]: list(running_config.unified_diff(generated_config)) -Out[1]: -['vlan 3', - ' - name switch_mgmt_10.0.4.0/24', - ' + name switch_mgmt_10.0.3.0/24', - 'interface Vlan2', - ' - shutdown', - ' + mtu 9000', - ' + ip access-group TEST in', - ' + no shutdown', - 'interface Vlan3', - ' - description switch_mgmt_10.0.4.0/24', - ' - ip address 10.0.4.1 255.255.0.0', - ' + description switch_mgmt_10.0.3.0/24', - ' + ip address 10.0.3.1 255.255.0.0', - '+ vlan 4', - ' + name switch_mgmt_10.0.4.0/24', - '+ interface Vlan4', - ' + mtu 9000', - ' + description switch_mgmt_10.0.4.0/24', - ' + ip address 10.0.4.1 255.255.0.0', - ' + ip access-group TEST in', - ' + no shutdown'] -``` - - - -## Future Config - -Starting in version 2.2.0, a featured called future config was introduced. It attempts to predict the running config after a change is applied. - -This feature is useful in cases where you need to determine what the configuration state will be after a change is applied. Such as: -- Ensuring that a configuration change was applied successfully to a device. - - i.e. Does the post-change config match the predicted future config? -- Providing a future state config that can be fed into batfish, or similar, to predict if a change will cause an impact. -- Building rollback configs. If you have the future config state, then generating a rollback config can be done by simply building the remediation config in the reverse direction `rollback = future.config_to_get_to(running)`. - - If you are building rollbacks for a series of config changes, you can feed the post-change-1 future config into the process for determining the post-change-2 future config e.g. - ```shell - post_change_1_config = running_config.future(change_1_config) - change_1_rollback_config = post_change_1_config.config_to_get_to(running_config) - post_change_2_config = post_change_1_config.future(change_2_config) - change_2_rollback_config = post_change_2_config.config_to_get_to(post_change_1_config) - ... - ``` - -In its current state, this algorithm does not consider: -- negate a numbered ACL when removing an item -- sectional exiting -- negate with -- idempotent command blacklist -- idempotent_acl_check -- and likely others - -```bash -In [1]: from hier_config import HConfig, Host - ...: - ...: - ...: host = Host("test.dfw1", "ios") - ...: running_config = HConfig(host) - ...: running_config.load_from_file("./tests/fixtures/running_config.conf") - ...: remediation_config = HConfig(host) - ...: remediation_config.load_from_file("./tests/fixtures/remediation_config_without_tags.conf") - ...: future_config = running_config.future(remediation_config) - ...: - ...: print("\n##### running config") - ...: for line in running_config.all_children(): - ...: print(line.cisco_style_text()) - ...: - ...: print("\n##### remediation config") - ...: for line in remediation_config.all_children(): - ...: print(line.cisco_style_text()) - ...: - ...: print("\n##### future config") - ...: for line in future_config.all_children(): - ...: print(line.cisco_style_text()) - ...: - -##### running config -hostname aggr-example.rtr -ip access-list extended TEST - 10 permit ip 10.0.0.0 0.0.0.7 any -vlan 2 - name switch_mgmt_10.0.2.0/24 -vlan 3 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - descripton switch_10.0.2.0/24 - ip address 10.0.2.1 255.255.255.0 - shutdown -interface Vlan3 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown - -##### remediation config -vlan 3 - name switch_mgmt_10.0.3.0/24 -vlan 4 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - mtu 9000 - ip access-group TEST in - no shutdown -interface Vlan3 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown - -##### future config -vlan 3 - name switch_mgmt_10.0.3.0/24 -vlan 4 - name switch_mgmt_10.0.4.0/24 -interface Vlan2 - mtu 9000 - ip access-group TEST in - descripton switch_10.0.2.0/24 - ip address 10.0.2.1 255.255.255.0 -interface Vlan3 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 - mtu 9000 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown -hostname aggr-example.rtr -ip access-list extended TEST - 10 permit ip 10.0.0.0 0.0.0.7 any -vlan 2 - name switch_mgmt_10.0.2.0/24 -``` - -## JunOS-style Syntax Remediation -"set" based operating systems can now be remediated in experimental capacity. Here is an example of a JunOS style remediation. - -``` -$ cat ./tests/fixtures/running_config_flat_junos.confset system host-name aggr-example.rtr - -set firewall family inet filter TEST term 1 from source-address 10.0.0.0/29 -set firewall family inet filter TEST term 1 then accept - -set vlans switch_mgmt_10.0.2.0/24 vlan-id 2 -set vlans switch_mgmt_10.0.2.0/24 l3-interface irb.2 - -set vlans switch_mgmt_10.0.4.0/24 vlan-id 3 -set vlans switch_mgmt_10.0.4.0/24 l3-interface irb.3 - -set interfaces irb unit 2 family inet address 10.0.2.1/24 -set interfaces irb unit 2 family inet description "switch_10.0.2.0/24" -set interfaces irb unit 2 family inet disable - -set interfaces irb unit 3 family inet address 10.0.4.1/16 -set interfaces irb unit 3 family inet filter input TEST -set interfaces irb unit 3 family inet mtu 9000 -set interfaces irb unit 3 family inet description "switch_mgmt_10.0.4.0/24" - - -$ python3 -Python 3.8.10 (default, Nov 22 2023, 10:22:35) -[GCC 9.4.0] on linux -Type "help", "copyright", "credits" or "license" for more information. ->>> import yaml ->>> from hier_config import Host ->>> ->>> host = Host('example.rtr', 'junos') ->>> ->>> # Build Hierarchical Configuration object for the Running Config ->>> host.load_running_config_from_file("./tests/fixtures/running_config_flat_junos.conf") ->>> ->>> # Build Hierarchical Configuration object for the Generated Config ->>> host.load_generated_config_from_file("./tests/fixtures/generated_config_flat_junos.conf") ->>> ->>> # Build and Print the all lines of the remediation config ->>> print(host.remediation_config_filtered_text({}, {})) -delete vlans switch_mgmt_10.0.4.0/24 vlan-id 3 -delete vlans switch_mgmt_10.0.4.0/24 l3-interface irb.3 -delete interfaces irb unit 2 family inet disable -delete interfaces irb unit 3 family inet address 10.0.4.1/16 -delete interfaces irb unit 3 family inet description "switch_mgmt_10.0.4.0/24" -set vlans switch_mgmt_10.0.3.0/24 vlan-id 3 -set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3 -set vlans switch_mgmt_10.0.4.0/24 vlan-id 4 -set vlans switch_mgmt_10.0.4.0/24 l3-interface irb.4 -set interfaces irb unit 2 family inet filter input TEST -set interfaces irb unit 2 family inet mtu 9000 -set interfaces irb unit 3 family inet address 10.0.3.1/16 -set interfaces irb unit 3 family inet description "switch_mgmt_10.0.3.0/24" -set interfaces irb unit 4 family inet address 10.0.4.1/16 -set interfaces irb unit 4 family inet filter input TEST -set interfaces irb unit 4 family inet mtu 9000 -set interfaces irb unit 4 family inet description "switch_mgmt_10.0.4.0/24" -``` - -Configurations loaded into Hier Config as Juniper-style syntax are converted to a flat `set` based configuration format. Remediations are then rendered using this `set` style syntax. - -``` -$ cat ./tests/fixtures/running_config_junos.conf -system { - host-name aggr-example.rtr; -} - -firewall { - family inet { - filter TEST { - term 1 { - from { - source-address 10.0.0.0/29; - } - then { - accept; - } - } - } - } -} - -vlans { - switch_mgmt_10.0.2.0/24 { - vlan-id 2; - l3-interface irb.2; - } - switch_mgmt_10.0.4.0/24 { - vlan-id 3; - l3-interface irb.3; - } -} - -interfaces { - irb { - unit 2 { - family inet { - address 10.0.2.1/24; - description "switch_10.0.2.0/24"; - disable; - } - } - unit 3 { - family inet { - address 10.0.4.1/16; - filter { - input TEST; - } - mtu 9000; - description "switch_mgmt_10.0.4.0/24"; - } - } - } -} - -$ python3 -Python 3.8.10 (default, Nov 22 2023, 10:22:35) -[GCC 9.4.0] on linux -Type "help", "copyright", "credits" or "license" for more information. ->>> import yaml ->>> from hier_config import Host ->>> ->>> host = Host('example.rtr', 'junos') ->>> ->>> # Build Hierarchical Configuration object for the Running Config ->>> host.load_running_config_from_file("./tests/fixtures/running_config_junos.conf") ->>> ->>> # Build Hierarchical Configuration object for the Generated Config ->>> host.load_generated_config_from_file("./tests/fixtures/generated_config_junos.conf") ->>> ->>> # Build and Print the all lines of the remediation config ->>> print(host.remediation_config_filtered_text({}, {})) -delete vlans switch_mgmt_10.0.4.0/24 vlan-id 3 -delete vlans switch_mgmt_10.0.4.0/24 l3-interface irb.3 -delete interfaces irb unit 2 family inet description "switch_10.0.2.0/24" -delete interfaces irb unit 2 family inet disable -delete interfaces irb unit 3 family inet address 10.0.4.1/16 -delete interfaces irb unit 3 family inet description "switch_mgmt_10.0.4.0/24" -set vlans switch_mgmt_10.0.3.0/24 vlan-id 3 -set vlans switch_mgmt_10.0.3.0/24 l3-interface irb.3 -set vlans switch_mgmt_10.0.4.0/24 vlan-id 4 -set vlans switch_mgmt_10.0.4.0/24 l3-interface irb.4 -set interfaces irb unit 2 family inet filter input TEST -set interfaces irb unit 2 family inet mtu 9000 -set interfaces irb unit 2 family inet description "switch_mgmt_10.0.2.0/24" -set interfaces irb unit 3 family inet address 10.0.3.1/16 -set interfaces irb unit 3 family inet description "switch_mgmt_10.0.3.0/24" -set interfaces irb unit 4 family inet address 10.0.4.1/16 -set interfaces irb unit 4 family inet filter input TEST -set interfaces irb unit 4 family inet mtu 9000 -set interfaces irb unit 4 family inet description "switch_mgmt_10.0.4.0/24" -``` \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md index d523987..9ab78a1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,108 +1,50 @@ -# hier_config Up and Running +# Getting Started with hier_config -Hierarchical Configuration doesn't communicate with devices themselves. It simply reads configuration data and creates a remediation plan based on the input from a running config and the input from a generated config. +Hier Config is a Python library that assists with remediating network configurations by comparing a device's current configuration (running config) with its intended configuration (generated config). Hier Config v3 processes configuration data without connecting to devices, enabling configuration analysis and remediation. -The very first thing that needs to happen is that a hier_config Host object needs to be initiated for a device. To do this, import the hier_config Host class. +## Step 1: Import Required Classes + +To use `WorkflowRemediation`, you’ll import it along with `get_hconfig` (for generating configuration objects) and `Platform` (for specifying the operating system driver). ```python -from hier_config import Host +from hier_config import get_hconfig, Platform, WorkflowRemediation ``` With the Host class imported, it can be utilized to create host objects. -```python -host = Host(hostname="aggr-example.rtr", os="ios") -``` +## Step 2: Creating HConfig Objects for Configurations -Once a host object has been created, the running configuration and generated configurations of a network device can be loaded into the host object. These configurations can be loaded in two ways. If you already have the configurations loaded as strings in memory, you can load them from the strings. +Use `get_hconfig` to create HConfig objects for both the running and intended configurations. Specify the platform with `Platform.CISCO_IOS`, `Platform.CISCO_NXOS`, etc., based on the device type. -*Example of loading configs from in memory strings*: ```python +# Define running and intended configurations as strings +running_config_text = open("./tests/fixtures/running_config.conf").read() +generated_config_text = open("./tests/fixtures/generated_config.conf").read() -running_config = """hostname aggr-example.rtr -! -ip access-list extended TEST - 10 permit ip 10.0.0.0 0.0.0.7 any -! -vlan 2 - name switch_mgmt_10.0.2.0/24 -! -vlan 3 - name switch_mgmt_10.0.4.0/24 -! -interface Vlan2 - descripton switch_10.0.2.0/24 - ip address 10.0.2.1 255.255.255.0 - shutdown -! -interface Vlan3 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown""" - -generated_config = """hostname aggr-example.rtr -! -ip access-list extended TEST - 10 permit ip 10.0.0.0 0.0.0.7 any -! -vlan 2 - name switch_mgmt_10.0.2.0/24 -! -vlan 3 - name switch_mgmt_10.0.3.0/24 -! -vlan 4 - name switch_mgmt_10.0.4.0/24 -! -interface Vlan2 - mtu 9000 - descripton switch_10.0.2.0/24 - ip address 10.0.2.1 255.255.255.0 - ip access-group TEST in - no shutdown -! -interface Vlan3 - mtu 9000 - description switch_mgmt_10.0.3.0/24 - ip address 10.0.3.1 255.255.0.0 - ip access-group TEST in - no shutdown -! -interface Vlan4 - mtu 9000 - description switch_mgmt_10.0.4.0/24 - ip address 10.0.4.1 255.255.0.0 - ip access-group TEST in - no shutdown""" - -host.load_running_config(config_text=running_config) -host.load_generated_config(config_text=generated_config) -``` -The second method for loading configs into the host object is loading the configs from files. +# Create HConfig objects for running and intended configurations +running_config = get_hconfig(Platform.CISCO_IOS, running_config_text) +generated_config = get_hconfig(Platform.CISCO_IOS, generated_config_text) -*Example of loading configs from files.* -```python -host.load_running_config_from_file("./tests/fixtures/running_config.conf") -host.load_generated_config_from_file("./tests/fixtures/generated_config.conf") ``` -Once the configs are loaded into the host object, a remediation can be created. +## Step 3: Initializing WorkflowRemediation and Generating Remediation + +With the HConfig objects created, initialize `WorkflowRemediation` to calculate the required remediation steps. ```python -host.remediation_config() +# Initialize WorkflowRemediation with the running and intended configurations +workflow = WorkflowRemediation(running_config, generated_config) ``` -`host.remediation_config()` is loaded as a python object. To view the results of the remediation, call the `host.remediation_config_filtered_text(include_tags={}, exclude_tags={})` method. +### Generating the Remediation Configuration + +The `remediation_config` attribute generates the configuration needed to apply the intended changes to the device. ```python -print(host.remediation_config_filtered_text(include_tags={}, exclude_tags={})) +print(workflow.remediation_config) ``` -> If you're using the examples from the `/tests/fixtures` folder in the [github](https://github.com/netdevops/hier_config/) repository, you should see an output that resembles: - ```text vlan 3 name switch_mgmt_10.0.3.0/24 @@ -122,3 +64,25 @@ interface Vlan4 ip access-group TEST in no shutdown ``` + +### Generating the Rollback Configuration + +Similarly, the `rollback_config` attribute generates a configuration that can revert the changes, restoring the device to its original state. + +```python +print(workflow.rollback_config) +``` + +```text +no vlan 4 +no interface Vlan4 +vlan 3 + name switch_mgmt_10.0.4.0/24 +interface Vlan2 + no mtu 9000 + no ip access-group TEST in + shutdown +interface Vlan3 + description switch_mgmt_10.0.4.0/24 + ip address 10.0.4.1 255.255.0.0 +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index bf09b39..3e0f842 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,15 @@ # Introduction -Welcome to the Hierarchical Configuration documentation site. Hierarchical Configuration, also known as `hier_config`, is a python library is able to take a running configuration of a network device, compare it to its intended configuration, and build the remediation steps necessary to bring a device into spec with its intended configuration. +Welcome to the Hierarchical Configuration documentation site. Hierarchical Configuration, also known as `hier_config`, is a Python library designed to take a running configuration from a network device, compare it to its intended configuration, and build the remediation steps necessary to bring a device into compliance with its intended configuration. -Hierarchical Configuraiton has been used extensively on: +Hierarchical Configuration has been used extensively on: - [x] Cisco IOS - [x] Cisco IOSXR - [x] Cisco NXOS - [x] Arista EOS +- [x] HP Procurve (Aruba AOSS) -However, any NOS that utilizes a CLI syntax that is structured in a similar fasion to IOS should work mostly out of the box. \ No newline at end of file +In addition to the Cisco-style syntax, hier_config offers experimental support for Juniper-style configurations using set and delete commands. This allows users to remediate Junos configurations in native syntax. However, please note that Juniper syntax support is still in an experimental phase and has not been tested extensively. Use with caution in production environments. + +Hier Config is compatible with any NOS that utilizes a structured CLI syntax similar to Cisco IOS or Junos OS. diff --git a/docs/install.md b/docs/install.md index c9071d8..1b8ebb1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2,12 +2,12 @@ > Hierarchical Configuration requires a minimum Python version of 3.8. -Hierarchical Configuration can be installed directly from github or with pip: +Hierarchical Configuration can be installed directly from GitHub or with pip: + +## Pip +1. Install from [PyPI](https://pypi.org/project/hier-config/): `pip install hier-config` ## Github 1. [Install Poetry](https://python-poetry.org/docs/#installation) 2. Clone the Repository: `git clone git@github.com:netdevops/hier_config.git` 3. Install hier_config: `cd hier_config && poetry install` - -## Pip -1. Install from [PyPI](https://pypi.org/project/hier-config/): `pip install hier-config` diff --git a/hier_config/__init__.py b/hier_config/__init__.py index 0d4ad50..d307054 100644 --- a/hier_config/__init__.py +++ b/hier_config/__init__.py @@ -1,5 +1,23 @@ -from .base import HConfigBase -from .root import HConfig from .child import HConfigChild -from .host import Host -from . import text_match +from .constructors import ( + get_hconfig, + get_hconfig_driver, + get_hconfig_fast_load, + get_hconfig_from_dump, + get_hconfig_view, +) +from .models import Platform +from .root import HConfig +from .workflows import WorkflowRemediation + +__all__ = ( + "HConfig", + "HConfigChild", + "Platform", + "WorkflowRemediation", + "get_hconfig", + "get_hconfig_driver", + "get_hconfig_fast_load", + "get_hconfig_from_dump", + "get_hconfig_view", +) diff --git a/hier_config/base.py b/hier_config/base.py index 85b6325..598099c 100644 --- a/hier_config/base.py +++ b/hier_config/base.py @@ -1,41 +1,32 @@ from __future__ import annotations -from typing import ( - Optional, - List, - Iterator, - Dict, - Tuple, - Union, - Set, - TYPE_CHECKING, - Type, -) -from logging import getLogger + from abc import ABC, abstractmethod -from functools import cached_property from itertools import chain +from logging import getLogger +from typing import TYPE_CHECKING, Optional, Union -from . import text_match +from .exceptions import DuplicateChildError if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from .child import HConfigChild + from .models import MatchRule, SetLikeOfStr + from .platforms.driver_base import HConfigDriverBase from .root import HConfig - from .host import Host logger = getLogger(__name__) -class HConfigBase(ABC): # pylint: disable=too-many-public-methods - def __init__(self) -> None: - self.children: List[HConfigChild] = [] - self.children_dict: Dict[str, HConfigChild] = {} - self.host: Host +class HConfigBase(ABC): # noqa: PLR0904 + __slots__ = ("children", "children_dict") - def __str__(self) -> str: - return "\n".join(c.cisco_style_text() for c in self.all_children()) + def __init__(self) -> None: + self.children: list[HConfigChild] = [] + self.children_dict: dict[str, HConfigChild] = {} def __len__(self) -> int: - return len(list(self.all_children())) + return len(tuple(self.all_children())) def __bool__(self) -> bool: return True @@ -43,33 +34,21 @@ def __bool__(self) -> bool: def __contains__(self, item: str) -> bool: return item in self.children_dict - def __eq__(self, other: object) -> bool: - if not isinstance(other, HConfigBase): - return NotImplemented - - if len(self.children) != len(other.children): - return False - - for self_child, other_child in zip( - sorted(self.children), sorted(other.children) - ): - if self_child != other_child: - return False - - return True - @abstractmethod - def _duplicate_child_allowed_check(self) -> bool: + def __hash__(self) -> int: pass + def __iter__(self) -> Iterator[HConfigChild]: + return iter(self.children) + @property @abstractmethod - def options(self) -> dict: + def root(self) -> HConfig: pass @property @abstractmethod - def root(self) -> HConfig: + def driver(self) -> HConfigDriverBase: pass @abstractmethod @@ -80,226 +59,212 @@ def lineage(self) -> Iterator[HConfigChild]: def depth(self) -> int: pass - @property - @abstractmethod - def logs(self) -> List[str]: - pass - - @property - @abstractmethod - def _child_class(self) -> Type[HConfigChild]: - pass - - def has_children(self) -> bool: - return bool(self.children) - - def add_children_deep(self, lines: List[str]) -> None: - """Add child instances of HConfigChild deeply""" - if lines: - child = self.add_child(lines.pop(0)) - child.add_children_deep(lines) - - def add_children(self, lines: List[str]) -> None: - """Add child instances of HConfigChild""" + def add_children(self, lines: Iterable[str]) -> None: + """Add child instances of HConfigChild.""" for line in lines: self.add_child(line) def add_child( self, text: str, - alert_on_duplicate: bool = False, - idx: Optional[int] = None, + *, + raise_on_duplicate: bool = False, force_duplicate: bool = False, ) -> HConfigChild: - """Add a child instance of HConfigChild""" + """Add a child instance of HConfigChild.""" + if not text: + message = "text was empty" + raise ValueError(message) - if idx is None: - idx = len(self.children) # if child does not exist if text not in self: - new_item = self._child_class(self, text) # type: ignore - self.children.insert(idx, new_item) + new_item = self._instantiate_child(text) + self.children.append(new_item) self.children_dict[text] = new_item return new_item # if child does exist and is allowed to be installed as a duplicate - if self._duplicate_child_allowed_check() or force_duplicate: - new_item = self._child_class(self, text) # type: ignore - self.children.insert(idx, new_item) - self.rebuild_children_dict() + if self._is_duplicate_child_allowed() or force_duplicate: + new_item = self._instantiate_child(text) + self.children.append(new_item) return new_item - # If the child is already present and the parent does not allow - # duplicate children, return the existing child - # Ignore duplicate remarks in ACLs - if alert_on_duplicate and not text.startswith("remark "): - self.logs.append(f"Found a duplicate section: {list(self.path()) + [text]}") + # If the child is already present and the parent does not allow for it + if raise_on_duplicate: + message = f"Found a duplicate section: {(*self.path(), text)}" + raise DuplicateChildError(message) return self.children_dict[text] - def path(self) -> Iterator[str]: + def path(self) -> Iterator[str]: # noqa: PLR6301 yield from () def add_deep_copy_of( - self, child_to_add: HConfigChild, merged: bool = False + self, + child_to_add: HConfigChild, + *, + merged: bool = False, ) -> HConfigChild: - """Add a nested copy of a child to self""" + """Add a nested copy of a child to self.""" new_child = self.add_shallow_copy_of(child_to_add, merged=merged) for child in child_to_add.children: new_child.add_deep_copy_of(child, merged=merged) return new_child - def to_tag_spec(self, tags: Set[str]) -> List[dict]: - """ - Returns the configuration as a tag spec definition - - This is handy when you have a segment of config and need to - generate a tag spec to tag configuration in another instance - """ - tag_spec = [] - for child in self.all_children(): - if not child.children: - child_spec = [{"equals": t} for t in child.path()] - tag_spec.append({"section": child_spec, "add_tags": tags}) - return tag_spec - - def del_child_by_text(self, text: str) -> None: - """Delete all children with the provided text""" + def delete_child_by_text(self, text: str) -> None: + """Delete all children with the provided text.""" if text in self.children_dict: self.children[:] = [c for c in self.children if c.text != text] self.rebuild_children_dict() - def del_child(self, child: HConfigChild) -> None: - """Delete a child from self.children and self.children_dict""" - try: - self.children.remove(child) - except ValueError: - pass - else: + def delete_child(self, child: HConfigChild) -> None: + """Delete a child from self.children and self.children_dict.""" + old_len = len(self.children) + self.children = [c for c in self.children if c is not child] + if old_len != len(self.children): self.rebuild_children_dict() - def all_children_sorted_untagged(self) -> Iterator[HConfigChild]: - """Yield all children recursively that are untagged""" - yield from (c for c in self.all_children_sorted() if None in c.tags) - def all_children_sorted(self) -> Iterator[HConfigChild]: - """Recursively find and yield all children sorted at each hierarchy""" + """Recursively find and yield all children sorted at each hierarchy.""" for child in sorted(self.children): yield child yield from child.all_children_sorted() - def all_children_sorted_with_lineage_rules( - self, rules: List[dict] - ) -> Iterator[HConfigChild]: - """Recursively find and yield all children sorted at each hierarchy given lineage rules""" - yielded = set() - matched: Set[HConfigChild] = set() - # pylint: disable=too-many-nested-blocks - for child in self.all_children_sorted(): - for ancestor in child.lineage(): - if ancestor in matched: - yield child - yielded.add(child) - break - else: - for rule in rules: - if child.lineage_test(rule, False): - matched.add(child) - for ancestor in child.lineage(): - if ancestor in yielded: - continue - yield ancestor - yielded.add(ancestor) - break - def all_children(self) -> Iterator[HConfigChild]: - """Recursively find and yield all children at each hierarchy""" + """Recursively find and yield all children at each hierarchy.""" for child in self.children: yield child yield from child.all_children() - def get_child(self, test: str, expression: str) -> Optional[HConfigChild]: - """Find a child by text_match rule. If it is not found, return None""" - if test == "equals" and isinstance(expression, str): - return self.children_dict.get(expression, None) - - return next(self.get_children(test, expression), None) - def get_child_deep( - self, test_expression_pairs: List[Tuple[str, str]] + self, match_rules: tuple[MatchRule, ...] ) -> Optional[HConfigChild]: - """ - Find a child recursively with a list of test/expression pairs + """Find the first child recursively given a tuple of MatchRules.""" + return next(self.get_children_deep(match_rules), None) - e.g. - - .. code:: python - - result = hier_obj.get_child_deep([('equals', 'control-plane'), - ('equals', 'service-policy input system-cpp-policy')]) - """ + def get_children_deep( + self, + match_rules: tuple[MatchRule, ...], + ) -> Iterator[HConfigChild]: + """Find children recursively given a tuple of MatchRules.""" + rule = match_rules[0] + remaining_rules = match_rules[1:] + for child in self.get_children( + equals=rule.equals, + startswith=rule.startswith, + endswith=rule.endswith, + contains=rule.contains, + re_search=rule.re_search, + ): + if remaining_rules: + yield from child.get_children_deep(remaining_rules) + else: + yield child - test, expression = test_expression_pairs.pop(0) - if test == "equals": - result = self.children_dict.get(expression, None) - if result and test_expression_pairs: - return result.get_child_deep(test_expression_pairs) - return result - - try: - result = next(self.get_children(test, expression)) - except StopIteration: - return None - if result and test_expression_pairs: - return result.get_child_deep(test_expression_pairs) - return result - - def get_children(self, test: str, expression: str) -> Iterator[HConfigChild]: + def get_child( + self, + *, + equals: Union[str, SetLikeOfStr, None] = None, + startswith: Union[str, tuple[str, ...], None] = None, + endswith: Union[str, tuple[str, ...], None] = None, + contains: Union[str, tuple[str, ...], None] = None, + re_search: Optional[str] = None, + ) -> Optional[HConfigChild]: + """Find a child by text_match rule. If it is not found, return None.""" + return next( + self.get_children( + equals=equals, + startswith=startswith, + endswith=endswith, + contains=contains, + re_search=re_search, + ), + None, + ) + + def get_children( + self, + *, + equals: Union[str, SetLikeOfStr, None] = None, + startswith: Union[str, tuple[str, ...], None] = None, + endswith: Union[str, tuple[str, ...], None] = None, + contains: Union[str, tuple[str, ...], None] = None, + re_search: Optional[str] = None, + ) -> Iterator[HConfigChild]: """Find all children matching a text_match rule and return them.""" - for child in self.children: - if text_match.dict_call(test, child.text, expression): + # For isinstance(equals, str) only matches, find the first child using children_dict + children_slice = slice(None, None) + if ( + isinstance(equals, str) + and startswith is endswith is contains is re_search is None + ): + if child := self.children_dict.get(equals): + yield child + children_slice = slice(self.children.index(child) + 1, None) + else: + return + + elif ( + isinstance(startswith, (str, tuple)) + and equals is endswith is contains is re_search is None + ): + duplicates_allowed = None + for child_text, child in self.children_dict.items(): + if child_text.startswith(startswith): + yield child + if duplicates_allowed is None: + duplicates_allowed = self._is_duplicate_child_allowed() + if duplicates_allowed: + children_slice = slice(self.children.index(child) + 1, None) + break + else: + return + + for child in self.children[children_slice]: + if child.is_match( + equals=equals, + startswith=startswith, + endswith=endswith, + contains=contains, + re_search=re_search, + ): yield child def add_shallow_copy_of( - self, child_to_add: HConfigChild, merged: bool = False + self, + child_to_add: HConfigChild, + *, + merged: bool = False, ) -> HConfigChild: - """Add a nested copy of a child_to_add to self.children""" - + """Add a nested copy of a child_to_add to self.children.""" new_child = self.add_child(child_to_add.text) if merged: - new_child.instances.append( - { - "hostname": child_to_add.host.hostname, - "comments": child_to_add.comments, - "tags": child_to_add.tags, - } - ) + new_child.instances.append(child_to_add.instance) new_child.comments.update(child_to_add.comments) new_child.order_weight = child_to_add.order_weight if child_to_add.is_leaf: - new_child.append_tags({t for t in child_to_add.tags if isinstance(t, str)}) + new_child.tags_add(child_to_add.tags) return new_child def rebuild_children_dict(self) -> None: - """Rebuild self.children_dict""" + """Rebuild self.children_dict.""" self.children_dict = {} for child in self.children: self.children_dict.setdefault(child.text, child) def delete_all_children(self) -> None: - """Delete all children""" + """Delete all children.""" self.children.clear() self.rebuild_children_dict() def unified_diff(self, target: Union[HConfig, HConfigChild]) -> Iterator[str]: - """ - provides a similar output to difflib.unified_diff() - - In its current state, this algorithm does not consider duplicate child differences. + """In its current state, this algorithm does not consider duplicate child differences. e.g. two instances `endif` in an IOS-XR route-policy. It also does not respect the order of commands where it may count, such as in ACLs. In the case of ACLs, they should contain sequence numbers if order is important. + + provides a similar output to difflib.unified_diff() """ # if a self child is missing from the target "- self_child.text" for self_child in self.children: @@ -323,46 +288,67 @@ def unified_diff(self, target: Union[HConfig, HConfigChild]) -> Iterator[str]: for c in target_child.all_children_sorted() ) - def _future( + def _future_pre( + self, config: Union[HConfig, HConfigChild] + ) -> tuple[set[str], set[str]]: + negated_or_recursed: set[str] = set() + config_children_ignore: set[str] = set() + for self_child in self.children: + # Is the command effectively negating a command in self.children? + if (negation_text := self.root.driver.negate_with(self_child)) and ( + config_child := config.get_child(equals=negation_text) + ): + negated_or_recursed.add(self_child.text) + config_children_ignore.add(config_child.text) + return negated_or_recursed, config_children_ignore + + def _future( # noqa: C901 self, config: Union[HConfig, HConfigChild], future_config: Union[HConfig, HConfigChild], ) -> None: - """ - The below cases still need to be accounted for: + """The below cases still need to be accounted for: - negate a numbered ACL when removing an item - - sectional exiting - - negate with - - idempotent command blacklist - - idempotent_acl_check - - and likely others + - idempotent command avoid list + - and likely others. """ - negated_or_recursed = set() + negated_or_recursed, config_children_ignore = self._future_pre(config) + for config_child in config.children: + if config_child.text in config_children_ignore: + continue # sectional_overwrite - if config_child.sectional_overwrite_check(): - future_config.add_deep_copy_of(config_child) # sectional_overwrite_no_negate - elif config_child.sectional_overwrite_no_negate_check(): + if ( + config_child.use_sectional_overwrite() + or config_child.use_sectional_overwrite_without_negation() + ): future_config.add_deep_copy_of(config_child) # Idempotent commands - elif self_child := config_child.idempotent_for(self.children): + elif self_child := self.root.driver.idempotent_for( + config_child, + self.children, + ): future_config.add_deep_copy_of(config_child) negated_or_recursed.add(self_child.text) # config_child is already in self - elif self_child := self.get_child("equals", config_child.text): + elif self_child := self.get_child(equals=config_child.text): future_child = future_config.add_shallow_copy_of(self_child) - # pylint: disable=protected-access - self_child._future(config_child, future_child) + self_child._future(config_child, future_child) # noqa: SLF001 negated_or_recursed.add(config_child.text) # config_child is being negated - elif config_child.text.startswith(self._negation_prefix): - unnegated_command = config_child.text[len(self._negation_prefix) :] - if self.get_child("equals", unnegated_command): + elif config_child.text.startswith(self.driver.negation_prefix): + unnegated_command = config_child.text_without_negation + if self.get_child(equals=unnegated_command): negated_or_recursed.add(unnegated_command) # Account for "no ..." commands in the running config else: future_config.add_shallow_copy_of(config_child) + # The negated form of config_child is in self.children + elif self_child := self.get_child( + equals=f"{self.driver.negation_prefix}{config_child.text}", + ): + negated_or_recursed.add(self_child.text) # config_child is not in self and doesn't match a special case else: future_config.add_deep_copy_of(config_child) @@ -374,28 +360,35 @@ def _future( # self_child was not modified above and should be present in the future config future_config.add_deep_copy_of(self_child) + @abstractmethod + def _instantiate_child(self, text: str) -> HConfigChild: + pass + + @abstractmethod + def _is_duplicate_child_allowed(self) -> bool: + pass + def _with_tags( - self, tags: Set[str], new_instance: Union[HConfig, HConfigChild] + self, + tags: frozenset[str], + new_instance: Union[HConfig, HConfigChild], ) -> Union[HConfig, HConfigChild]: - """ - Returns a new instance containing only sub-objects - with one of the tags in tags - """ + """Adds children recursively that have a subset of tags.""" for child in self.children: - if tags.intersection(child.tags): + if tags.issubset(child.tags): new_child = new_instance.add_shallow_copy_of(child) - # pylint: disable=protected-access - child._with_tags(tags, new_instance=new_child) + child._with_tags(tags, new_instance=new_child) # noqa: SLF001 return new_instance def _config_to_get_to( - self, target: Union[HConfig, HConfigChild], delta: Union[HConfig, HConfigChild] + self, + target: Union[HConfig, HConfigChild], + delta: Union[HConfig, HConfigChild], ) -> Union[HConfig, HConfigChild]: - """ - Figures out what commands need to be executed to transition from self to target. + """Figures out what commands need to be executed to transition from self to target. self is the source data structure(i.e. the running_config), - target is the destination(i.e. generated_config) + target is the destination(i.e. generated_config). """ self._config_to_get_to_left(target, delta) @@ -414,52 +407,53 @@ def _difference( self, target: Union[HConfig, HConfigChild], delta: Union[HConfig, HConfigChild], + target_acl_children: Optional[dict[str, HConfigChild]] = None, + *, in_acl: bool = False, - target_acl_children: Optional[Dict[str, HConfigChild]] = None, ) -> Union[HConfig, HConfigChild]: + acl_sw_matches = tuple(f"ip{x} access-list " for x in ("", "v4", "v6")) + for self_child in self.children: # Not dealing with negations and defaults for now - if self_child.text.startswith((self._negation_prefix, "default ")): + if self_child.text.startswith((self.driver.negation_prefix, "default ")): continue if in_acl: # Ignore ACL sequence numbers - assert isinstance(target_acl_children, dict) + if target_acl_children is None: + message = "target_acl_children cannot be None" + raise TypeError(message) target_child = target_acl_children.get( - self._strip_acl_sequence_number(self_child) + self._strip_acl_sequence_number(self_child), ) else: - target_child = target.get_child("equals", self_child.text) + target_child = target.get_child(equals=self_child.text) if target_child is None: delta.add_deep_copy_of(self_child) else: delta_child = delta.add_child(self_child.text) - sw_matches = tuple(f"ip{x} access-list " for x in ("", "v4", "v6")) - - if self_child.text.startswith(sw_matches): - # pylint: disable=protected-access - self_child._difference( + if self_child.text.startswith(acl_sw_matches): + self_child._difference( # noqa: SLF001 target_child, delta_child, - in_acl=True, target_acl_children={ self._strip_acl_sequence_number(c): c for c in target_child.children }, + in_acl=True, ) else: - self_child._difference( # pylint: disable=protected-access - target_child, delta_child - ) - + self_child._difference(target_child, delta_child) # noqa: SLF001 if not delta_child.children: delta_child.delete() return delta def _config_to_get_to_left( - self, target: Union[HConfig, HConfigChild], delta: Union[HConfig, HConfigChild] + self, + target: Union[HConfig, HConfigChild], + delta: Union[HConfig, HConfigChild], ) -> None: # find self.children that are not in target.children # i.e. what needs to be negated or defaulted @@ -473,36 +467,30 @@ def _config_to_get_to_left( # in other but not self # add this node but not any children - if ( - self_child.text.startswith("set") - and self.options["negation"] == "delete" - ): - self_child.text = self_child.text.replace("set ", "", 1) - - deleted = delta.add_child(self_child.text) - deleted.negate() + negated = delta.add_child(self_child.text).negate() if self_child.children: - deleted.comments.add(f"removes {len(self_child.children) + 1} lines") + negated.comments.add(f"removes {len(self_child.children) + 1} lines") def _config_to_get_to_right( - self, target: Union[HConfig, HConfigChild], delta: Union[HConfig, HConfigChild] + self, + target: Union[HConfig, HConfigChild], + delta: Union[HConfig, HConfigChild], ) -> None: # find what would need to be added to source_config to get to self for target_child in target.children: # if the child exist, recurse into its children - if self_child := self.get_child("equals", target_child.text): + if self_child := self.get_child(equals=target_child.text): # This creates a new HConfigChild object just in case there are some delta children # Not very efficient, think of a way to not do this subtree = delta.add_child(target_child.text) - # pylint: disable=protected-access - self_child._config_to_get_to(target_child, subtree) + self_child._config_to_get_to(target_child, subtree) # noqa: SLF001 if not subtree.children: subtree.delete() # Do we need to rewrite the child and its children as well? - elif self_child.sectional_overwrite_check(): - target_child.overwrite_with(self_child, delta, True) - elif self_child.sectional_overwrite_no_negate_check(): - target_child.overwrite_with(self_child, delta, False) + elif self_child.use_sectional_overwrite(): + target_child.overwrite_with(self_child, delta) + elif self_child.use_sectional_overwrite_without_negation(): + target_child.overwrite_with(self_child, delta, negate=False) # the child is absent, add it else: new_item = delta.add_deep_copy_of(target_child) @@ -512,7 +500,3 @@ def _config_to_get_to_right( child.new_in_config = True if new_item.children: new_item.comments.add("new section") - - @cached_property - def _negation_prefix(self) -> str: - return str(self.options["negation"]) + " " diff --git a/hier_config/child.py b/hier_config/child.py index 7caf75d..f94fec7 100644 --- a/hier_config/child.py +++ b/hier_config/child.py @@ -1,44 +1,55 @@ from __future__ import annotations -from typing import ( - Optional, - Set, - Union, - Iterator, - List, - TYPE_CHECKING, - Type, - Iterable, - Tuple, -) -from logging import getLogger + from itertools import chain +from logging import getLogger +from re import search +from typing import TYPE_CHECKING, Any, Optional, Union from .base import HConfigBase -from . import text_match +from .models import Instance, MatchRule, SetLikeOfStr if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from hier_config.platforms.driver_base import HConfigDriverBase + from .root import HConfig logger = getLogger(__name__) -# pylint: disable=too-many-instance-attributes,too-many-public-methods -class HConfigChild(HConfigBase): - def __init__(self, parent: Union[HConfig, HConfigChild], text: str): +class HConfigChild( # noqa: PLR0904 pylint: disable=too-many-instance-attributes + HConfigBase, +): + __slots__ = ( + "_tags", + "_text", + "comments", + "facts", + "instances", + "new_in_config", + "order_weight", + "parent", + "real_indent_level", + ) + + def __init__(self, parent: Union[HConfig, HConfigChild], text: str) -> None: super().__init__() self.parent = parent - self.host = self.root.host self._text: str = text.strip() self.real_indent_level: int - # The intent is for self.order_weight values to range from 1 to 999 - # with the default weight being 500 - self.order_weight: int = 500 - self._tags: Set[str] = set() - self.comments: Set[str] = set() + # 0 is the default. Positive weights sink while negative weights rise. + self.order_weight: int = 0 + self._tags: set[str] = set() + self.comments: set[str] = set() self.new_in_config: bool = False - self.instances: List[dict] = [] - self.facts: dict = {} # To store externally inserted facts + self.instances: list[Instance] = [] + # To store externally inserted facts + self.facts: dict[Any, Any] = {} + + def __str__(self) -> str: + return "\n".join(self.lines(sectional_exiting=True)) def __repr__(self) -> str: return f"HConfigChild(HConfig{'' if self.parent is self.root else 'Child'}, {self.text})" @@ -47,7 +58,16 @@ def __lt__(self, other: HConfigChild) -> bool: return self.order_weight < other.order_weight def __hash__(self) -> int: - return id(self) + return hash( + ( + self.text, + # self.tags, + # self.comments, + self.new_in_config, + self.order_weight, + *self.children, + ), + ) def __eq__(self, other: object) -> bool: if not isinstance(other, HConfigChild): @@ -57,55 +77,88 @@ def __eq__(self, other: object) -> bool: self.text != other.text or self.tags != other.tags or self.comments != other.comments - or self.new_in_config != other.new_in_config ): return False - return super().__eq__(other) + + if len(self.children) != len(other.children): + return False + + return all( + self_child == other_child + for self_child, other_child in zip( + sorted(self.children), + sorted(other.children), + ) + ) def __ne__(self, other: object) -> bool: return not self.__eq__(other) + @property + def driver(self) -> HConfigDriverBase: + return self.root.driver + @property def text(self) -> str: return self._text @text.setter def text(self, value: str) -> None: - """ - Used for when self.text is changed after the object - is instantiated to rebuild the children dictionary + """Used for when self.text is changed after the object + is instantiated to rebuild the children dictionary. """ self._text = value.strip() self.parent.rebuild_children_dict() + @property + def text_without_negation(self) -> str: + return self.text.removeprefix(self.driver.negation_prefix) + @property def root(self) -> HConfig: - """returns the HConfig object at the base of the tree""" + """Returns the HConfig object at the base of the tree.""" return self.parent.root - @property - def logs(self) -> List[str]: - return self.root.logs + def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: + yield self.cisco_style_text() + for child in sorted(self.children): + yield from child.lines(sectional_exiting=sectional_exiting) - @property - def options(self) -> dict: - return self.root.options + if sectional_exiting and (exit_text := self.sectional_exit): + yield " " * self.driver.rules.indentation * self.depth() + exit_text @property - def _child_class(self) -> Type[HConfigChild]: - return HConfigChild + def sectional_exit(self) -> Optional[str]: + for rule in self.driver.rules.sectional_exiting: + if self.is_lineage_match(rule.match_rules): + if exit_text := rule.exit_text: + return exit_text + return None + + if not self.children: + return None + + return "exit" + + def delete_sectional_exit(self) -> None: + try: + potential_exit = self.children[-1] + except IndexError: + return + + if (exit_text := self.sectional_exit) and exit_text == potential_exit.text: + potential_exit.delete() def depth(self) -> int: - """Returns the distance to the root HConfig object i.e. indent level""" + """Returns the distance to the root HConfig object i.e. indent level.""" return self.parent.depth() + 1 def move(self, new_parent: Union[HConfig, HConfigChild]) -> None: - """ - move one HConfigChild object to different HConfig parent object + """Move one HConfigChild object to different HConfig parent object. .. code:: python - hier1 = HConfig(host=host) + hier1 = config_for_platform(host.platform) interface1 = hier1.add_child('interface Vlan2') interface1.add_child('ip address 10.0.0.1 255.255.255.252') @@ -114,38 +167,38 @@ def move(self, new_parent: Union[HConfig, HConfigChild]) -> None: interface1.move(hier2) :param new_parent: HConfigChild object -> type list - :return: None """ new_parent.children.append(self) new_parent.rebuild_children_dict() self.delete() def lineage(self) -> Iterator[HConfigChild]: - """Yields the lineage of parent objects, up to but excluding the root""" + """Yields the lineage of parent objects up to, but excluding, the root.""" yield from self.parent.lineage() yield self def path(self) -> Iterator[str]: - """Return a list of the text instance variables from self.lineage""" - for hier_object in self.lineage(): - yield hier_object.text + """Yields the text attribute of child objects up to, but excluding, the root.""" + for child in self.lineage(): + yield child.text def cisco_style_text( - self, style: str = "without_comments", tag: Optional[str] = None + self, + style: str = "without_comments", + tag: Optional[str] = None, ) -> str: - """Return a Cisco style formated line i.e. indentation_level + text ! comments""" - - comments = [] + """Return a Cisco style formated line i.e. indentation_level + text ! comments.""" + comments: list[str] = [] if style == "without_comments": pass elif style == "merged": # count the number of instances that have the tag instance_count = 0 - instance_comments: Set[str] = set() + instance_comments: set[str] = set() for instance in self.instances: - if tag is None or tag in instance["tags"]: + if tag is None or tag in instance.tags: instance_count += 1 - instance_comments.update(instance["comments"]) + instance_comments.update(instance.comments) # should the word 'instance' be plural? word = "instance" if instance_count == 1 else "instances" @@ -160,184 +213,154 @@ def cisco_style_text( @property def indentation(self) -> str: - return " " * (self.depth() - 1) + return " " * self.driver.rules.indentation * (self.depth() - 1) def delete(self) -> None: - """Delete the current object from its parent""" - self.parent.del_child(self) + """Delete the current object from its parent.""" + self.parent.delete_child(self) - def append_tag(self, tag: str) -> None: - """ - Add a tag to self._tags on all leaf nodes - """ + def tags_add(self, tag: Union[str, Iterable[str]]) -> None: + """Add a tag to self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.append_tag(tag) - else: + child.tags_add(tag) + elif isinstance(tag, str): self._tags.add(tag) - - def append_tags(self, tags: Union[str, List[str], Set[str]]) -> None: - """ - Add tags to self._tags on all leaf nodes - """ - tags = self._to_set(tags) - if self.is_branch: - for child in self.children: - child.append_tags(tags) else: - self._tags.update(tags) + self._tags.update(tag) - def remove_tag(self, tag: str) -> None: - """ - Remove a tag from self._tags on all leaf nodes - """ + def tags_remove(self, tag: Union[str, Iterable[str]]) -> None: + """Remove a tag from self._tags on all leaf nodes.""" if self.is_branch: for child in self.children: - child.remove_tag(tag) - else: + child.tags_remove(tag) + elif isinstance(tag, str): self._tags.remove(tag) - - def remove_tags(self, tags: Union[str, List[str], Set[str]]) -> None: - """ - Remove tags from self._tags on all leaf nodes - """ - tags = self._to_set(tags) - if self.is_branch: - for child in self.children: - child.remove_tags(tags) else: - self._tags.difference_update(tags) + self._tags.difference_update(tag) def negate(self) -> HConfigChild: - """Negate self.text""" - for rule in self.options["negation_negate_with"]: - if self.lineage_test(rule): - self.text = rule["use"] - return self + """Negate self.text.""" + if negate_with := self.driver.negate_with(self): + self.text = negate_with + return self + + if self.use_default_for_negation(self): + return self._default() - for rule in self.options["negation_default_when"]: - if self.lineage_test(rule): - return self._default() + return self.driver.swap_negation(self) - return self._swap_negation() + def use_default_for_negation(self, config: HConfigChild) -> bool: + return any( + config.is_lineage_match(rule.match_rules) + for rule in self.driver.rules.negation_default_when + ) @property def is_leaf(self) -> bool: - """returns True if there are no children and is not an instance of HConfig""" + """Returns True if there are no children and is not an instance of HConfig.""" return not self.is_branch @property def is_branch(self) -> bool: - """returns True if there are children or is an instance of HConfig""" + """Returns True if there are children or is an instance of HConfig.""" return bool(self.children) @property - def tags(self) -> Set[Optional[str]]: - """Recursive access to tags on all leaf nodes""" + def tags(self) -> frozenset[str]: + """Recursive access to tags on all leaf nodes.""" if self.is_branch: - found_tags = set() + found_tags: set[str] = set() for child in self.children: found_tags.update(child.tags) - return found_tags + return frozenset(found_tags) - # The getter can return a set containing None - # while the setter only accepts a set containing strs. - # mypy doesn't like this - return self._tags or {None} # type: ignore + return frozenset(self._tags) @tags.setter - def tags(self, value: Set[str]) -> None: - """Recursive access to tags on all leaf nodes""" + def tags(self, value: frozenset[str]) -> None: + """Recursive access to tags on all leaf nodes.""" if self.is_branch: for child in self.children: - # see comment in getter - child.tags = value # type: ignore + child.tags = value else: - self._tags = value + self._tags = set(value) def is_idempotent_command(self, other_children: Iterable[HConfigChild]) -> bool: """Determine if self.text is an idempotent change.""" - # Blacklist commands from matching as idempotent - for rule in self.options["idempotent_commands_blacklist"]: - if self.lineage_test(rule, True): + # Avoid list commands from matching as idempotent + for rule in self.driver.rules.idempotent_commands_avoid: + if self.is_lineage_match(rule.match_rules): return False - # Handles idempotent acl entry identification - if self._idempotent_acl_check(): - if self.host.os in {"iosxr"}: - self_sn = self.text.split(" ", 1)[0] - for other_child in other_children: - other_sn = other_child.text.split(" ", 1)[0] - if self_sn == other_sn: - return True - # Idempotent command identification - return bool(self.idempotent_for(other_children)) - - def idempotent_for( - self, other_children: Iterable[HConfigChild] - ) -> Optional[HConfigChild]: - for rule in self.options["idempotent_commands"]: - if self.lineage_test(rule, True): - for other_child in other_children: - if other_child.lineage_test(rule, True): - return other_child - return None - - def sectional_overwrite_no_negate_check(self) -> bool: - """ - Check self's text to see if negation should be handled by - overwriting the section without first negating it + return bool(self.driver.idempotent_for(self, other_children)) + + def use_sectional_overwrite_without_negation(self) -> bool: + """Check self's text to see if negation should be handled by + overwriting the section without first negating it. """ - for rule in self.options["sectional_overwrite_no_negate"]: - if self.lineage_test(rule): - return True - return False - - def sectional_overwrite_check(self) -> bool: - """Determines if self.text matches a sectional overwrite rule""" - for rule in self.options["sectional_overwrite"]: - if self.lineage_test(rule): - return True - return False + return any( + self.is_lineage_match(rule.match_rules) + for rule in self.driver.rules.sectional_overwrite_no_negate + ) + + def use_sectional_overwrite(self) -> bool: + """Determines if self.text matches a sectional overwrite rule.""" + return any( + self.is_lineage_match(rule.match_rules) + for rule in self.driver.rules.sectional_overwrite + ) def overwrite_with( self, other: HConfigChild, delta: Union[HConfig, HConfigChild], + *, negate: bool = True, ) -> None: - """Deletes delta.child[self.text], adds a deep copy of self to delta""" + """Deletes delta.child[self.text], adds a deep copy of self to delta.""" if other.children != self.children: if negate: - delta.del_child_by_text(self.text) + delta.delete_child_by_text(self.text) deleted = delta.add_child(self.text).negate() deleted.comments.add("dropping section") if self.children: - delta.del_child_by_text(self.text) + delta.delete_child_by_text(self.text) new_item = delta.add_deep_copy_of(self) new_item.comments.add("re-create section") def line_inclusion_test( - self, include_tags: Set[str], exclude_tags: Set[str] + self, + include_tags: Iterable[str], + exclude_tags: Iterable[str], ) -> bool: - """ - Given the line_tags, include_tags, and exclude_tags, - determine if the line should be included + """Given the line_tags, include_tags, and exclude_tags, + determine if the line should be included. """ include_line = False if include_tags: include_line = bool(self.tags.intersection(include_tags)) if exclude_tags and (include_line or not include_tags): - include_line = not bool(self.tags.intersection(exclude_tags)) + return not bool(self.tags.intersection(exclude_tags)) return include_line + @property + def instance(self) -> Instance: + return Instance( + id=id(self.root), + comments=frozenset(self.comments), + tags=frozenset(self.tags), + ) + def all_children_sorted_by_tags( - self, include_tags: Set[str], exclude_tags: Set[str] + self, + include_tags: Iterable[str], + exclude_tags: Iterable[str], ) -> Iterator[HConfigChild]: - """Yield all children recursively that match include/exclude tags""" + """Yield all children recursively that match include/exclude tags.""" if self.is_leaf: if self.line_inclusion_test(include_tags, exclude_tags): yield self @@ -345,158 +368,96 @@ def all_children_sorted_by_tags( self_iter = iter((self,)) for child in sorted(self.children): included_children = child.all_children_sorted_by_tags( - include_tags, exclude_tags + include_tags, + exclude_tags, ) if peek := next(included_children, None): yield from chain(self_iter, (peek,), included_children) - def lineage_test(self, rule: dict, strip_negation: bool = False) -> bool: - """A generic test against a lineage of HConfigChild objects""" - if rule.get("match_leaf", False): - lineage_obj: Iterator[HConfigChild] = (o for o in (self,)) - lineage_depth = 1 - else: - lineage_obj = self.lineage() - lineage_depth = self.depth() + def is_lineage_match(self, rules: tuple[MatchRule, ...]) -> bool: + """A generic test against a lineage of HConfigChild objects.""" + lineage = tuple(self.lineage()) + + return len(rules) == len(lineage) and all( + child.is_match( + equals=rule.equals, + startswith=rule.startswith, + endswith=rule.endswith, + contains=rule.contains, + re_search=rule.re_search, + ) + # add strict=True after 3.9 is deprecated + for (child, rule) in zip(reversed(lineage), reversed(rules)) + ) + + def is_match( # noqa: PLR0911 + self, + *, + equals: Union[str, SetLikeOfStr, None] = None, + startswith: Union[str, tuple[str, ...], None] = None, + endswith: Union[str, tuple[str, ...], None] = None, + contains: Union[str, tuple[str, ...], None] = None, + re_search: Optional[str] = None, + ) -> bool: + """True if `self.text` matches all the criteria. - rule_lineage_len = len(rule["lineage"]) - if rule_lineage_len != lineage_depth: + If all args are None, the function will return True. + If multiple args are provided, then all will need to match in order to return True. + """ + # Equals filter + if isinstance(equals, str): + if self.text != equals: + return False + elif ( # pylint: disable=confusing-consecutive-elif + isinstance(equals, frozenset) and self.text not in equals + ): return False - matches = 0 - for lineage_rule, section in zip(rule["lineage"], lineage_obj): - object_rules, text_match_rules = self._explode_lineage_rule(lineage_rule) + # Startswith filter + if isinstance(startswith, (str, tuple)) and not self.text.startswith( + startswith + ): + return False - if not self._lineage_eval_object_rules(object_rules, section): - return False + # Regex filter + if isinstance(re_search, str) and not search(re_search, self.text): + return False - # This removes negations for each section but honestly, - # we really only need to do this on the last one - if strip_negation: - if section.text.startswith(self._negation_prefix): - text = section.text[len(self._negation_prefix) :] - elif section.text.startswith("default "): - text = section.text[8:] - else: - text = section.text - else: - text = section.text - - if self._lineage_eval_text_match_rules(text_match_rules, text): - matches += 1 - continue + # The below filters are less commonly used + # Endswith filter + if isinstance(endswith, (str, tuple)) and not self.text.endswith(endswith): return False - return matches == rule_lineage_len + # Contains filter + if isinstance(contains, str): + if contains not in self.text: + return False + elif isinstance( # pylint: disable=confusing-consecutive-elif + contains, + tuple, + ) and not any(c in self.text for c in contains): + return False - def _swap_negation(self) -> HConfigChild: - """Swap negation of a self.text""" - if self.text.startswith(self._negation_prefix): - self.text = self.text[len(self._negation_prefix) :] - else: - self.text = self._negation_prefix + self.text + return True - return self + def add_children_deep(self, lines: Iterable[str]) -> HConfigChild: + """Add child instances of HConfigChild deeply.""" + base = self + for line in lines: + base = base.add_child(line) + return base def _default(self) -> HConfigChild: - """Default self.text""" - if self.text.startswith(self._negation_prefix): - self.text = "default " + self.text[len(self._negation_prefix) :] - else: - self.text = "default " + self.text + """Default self.text.""" + self.text = f"default {self.text_without_negation}" return self - def _idempotent_acl_check(self) -> bool: - """ - Handle conditional testing to determine if idempotent acl handling for iosxr should be used - """ - if self.host.os in {"iosxr"}: - if isinstance(self.parent, HConfigChild): - acl = ("ipv4 access-list ", "ipv6 access-list ") - if self.parent.text.startswith(acl): - return True - return False - - @staticmethod - def _explode_lineage_rule(rule: dict) -> Tuple[list, list]: - text_match_rules: List[dict] = [] - object_rules = [] - for test, expression in rule.items(): - if test in {"new_in_config", "negative_intersection_tags"}: - object_rules.append({"test": test, "expression": expression}) - elif test == "equals": - if isinstance(expression, list): - text_match_rules.append( - {"test": test, "expression": set(expression)} - ) - else: - text_match_rules.append({"test": test, "expression": {expression}}) - elif test in {"startswith", "endswith"}: - if isinstance(expression, list): - text_match_rules.append( - {"test": test, "expression": tuple(expression)} - ) - else: - text_match_rules.append({"test": test, "expression": (expression,)}) - elif isinstance(expression, list): - text_match_rules += [ - {"test": test, "expression": e} for e in expression - ] - else: - text_match_rules += [{"test": test, "expression": expression}] - return object_rules, text_match_rules - - def _lineage_eval_object_rules(self, rules: list, section: HConfigChild) -> bool: - """ - Evaluate a list of lineage object rules. - - All object rules must match in order to return True + def _instantiate_child(self, text: str) -> HConfigChild: + return HConfigChild(self, text) - """ - matches = 0 - for rule in rules: - if rule["test"] == "new_in_config": - if rule["expression"] == section.new_in_config: - matches += 1 - continue - return False - if rule["test"] == "negative_intersection_tags": - rule["expression"] = self._to_list(rule["expression"]) - if not set(rule["expression"]).intersection(section.tags): - matches += 1 - continue - return False - return matches == len(rules) - - @staticmethod - def _lineage_eval_text_match_rules(rules: list, text: str) -> bool: - """ - Evaluate a list of lineage text_match rules. - - Only one text_match rule must match in order to return True - """ - for rule in rules: - if text_match.dict_call(rule["test"], text, rule["expression"]): - return True - return False - - @staticmethod - def _to_list(obj: Union[list, object]) -> list: - return obj if isinstance(obj, list) else [obj] - - @staticmethod - def _to_set(items: Union[str, List[str], Set[str]]) -> Set[str]: - # There's code out in the wild that passes List[str] or str, need to normalize for now - if isinstance(items, list): - return set(items) - if isinstance(items, str): - return {items} - # Assume it's a set of str - return items - - def _duplicate_child_allowed_check(self) -> bool: - """Determine if duplicate(identical text) children are allowed under the parent""" - for rule in self.options["parent_allows_duplicate_child"]: - if self.lineage_test(rule): - return True - return False + def _is_duplicate_child_allowed(self) -> bool: + """Determine if duplicate(identical text) children are allowed under the parent.""" + return any( + self.is_lineage_match(rule.match_rules) + for rule in self.driver.rules.parent_allows_duplicate_child + ) diff --git a/hier_config/constructors.py b/hier_config/constructors.py new file mode 100644 index 0000000..fd8113a --- /dev/null +++ b/hier_config/constructors.py @@ -0,0 +1,346 @@ +from contextlib import suppress +from itertools import islice +from logging import getLogger +from pathlib import Path +from re import search, sub +from typing import Union + +from hier_config.platforms.driver_base import HConfigDriverBase + +from .child import HConfigChild +from .models import Dump, Platform +from .platforms.arista_eos.driver import HConfigDriverAristaEOS +from .platforms.arista_eos.view import HConfigViewAristaEOS +from .platforms.cisco_ios.driver import HConfigDriverCiscoIOS +from .platforms.cisco_ios.view import HConfigViewCiscoIOS +from .platforms.cisco_nxos.driver import HConfigDriverCiscoNXOS +from .platforms.cisco_nxos.view import HConfigViewCiscoNXOS +from .platforms.cisco_xr.driver import HConfigDriverCiscoIOSXR +from .platforms.cisco_xr.view import HConfigViewCiscoIOSXR +from .platforms.generic.driver import HConfigDriverGeneric +from .platforms.hp_comware5.driver import HConfigDriverHPComware5 +from .platforms.hp_procurve.driver import HConfigDriverHPProcurve +from .platforms.hp_procurve.view import HConfigViewHPProcurve +from .platforms.juniper_junos.driver import HConfigDriverJuniperJUNOS +from .platforms.view_base import HConfigViewBase +from .platforms.vyos.driver import HConfigDriverVYOS +from .root import HConfig + +logger = getLogger(__name__) + + +def get_hconfig_driver(platform: Platform) -> HConfigDriverBase: # noqa: PLR0911 + """Create base options on an OS level.""" + if platform == Platform.ARISTA_EOS: + return HConfigDriverAristaEOS() + if platform == Platform.CISCO_IOS: + return HConfigDriverCiscoIOS() + if platform == Platform.CISCO_NXOS: + return HConfigDriverCiscoNXOS() + if platform == Platform.CISCO_XR: + return HConfigDriverCiscoIOSXR() + if platform == Platform.GENERIC: + return HConfigDriverGeneric() + if platform == Platform.HP_PROCURVE: + return HConfigDriverHPProcurve() + if platform == Platform.HP_COMWARE5: + return HConfigDriverHPComware5() + if platform == Platform.JUNIPER_JUNOS: + return HConfigDriverJuniperJUNOS() + if platform == Platform.VYOS: + return HConfigDriverVYOS() + + message = f"Unsupported platform: {platform}" # type: ignore[unreachable] + raise ValueError(message) + + +def get_hconfig_view(config: HConfig) -> HConfigViewBase: + """Instantiates the appropriate HConfigView. + + If you implement your own HConfigView, you will likely need to create a function like this one locally. + """ + driver = config.driver + if isinstance(driver, HConfigDriverAristaEOS): + return HConfigViewAristaEOS(config) + if isinstance(driver, HConfigDriverCiscoIOS): + return HConfigViewCiscoIOS(config) + if isinstance(driver, HConfigDriverCiscoNXOS): + return HConfigViewCiscoNXOS(config) + if isinstance(driver, HConfigDriverCiscoIOSXR): + return HConfigViewCiscoIOSXR(config) + if isinstance(driver, HConfigDriverHPProcurve): + return HConfigViewHPProcurve(config) + + message = f"Unsupported platform: {config.driver.__class__.__name__}" + raise ValueError(message) + + +def get_hconfig( + platform_or_driver: Union[Platform, HConfigDriverBase], + config_raw: Union[Path, str] = "", +) -> HConfig: + if isinstance(config_raw, Path): + config_raw = config_raw.read_text(encoding="utf8") + + config = HConfig(_get_driver(platform_or_driver)) + for rule in config.driver.rules.full_text_sub: + config_raw = sub(rule.search, rule.replace, config_raw) + + _load_from_string_lines(config, config_raw) + + for child in tuple(config.all_children()): + child.delete_sectional_exit() + + for callback in config.driver.rules.post_load_callbacks: + callback(config) + + return config + + +def get_hconfig_from_dump( + platform_or_driver: Union[Platform, HConfigDriverBase], dump: Dump +) -> HConfig: + """Load an HConfig dump.""" + config = get_hconfig(_get_driver(platform_or_driver)) + last_item: Union[HConfig, HConfigChild] = config + for item in dump.lines: + # parent is the root + if item.depth == 1: + parent: Union[HConfig, HConfigChild] = config + # has the same parent + elif last_item.depth() == item.depth: + parent = last_item.parent + # is a child object + elif last_item.depth() + 1 == item.depth: + parent = last_item + # has a parent somewhere closer to the root but not the root + else: + parent = next(islice(last_item.lineage(), item.depth - 2, item.depth - 1)) + obj = parent.add_child(item.text, force_duplicate=True) + obj.tags = frozenset(item.tags) + obj.comments = set(item.comments) + obj.new_in_config = item.new_in_config + last_item = obj + + return config + + +def get_hconfig_fast_generic_load( + lines: Union[list[str], tuple[str, ...], str], +) -> HConfig: + return get_hconfig_fast_load(Platform.GENERIC, lines) + + +def get_hconfig_fast_load( + platform_or_driver: Union[Platform, HConfigDriverBase], + lines: Union[list[str], tuple[str, ...], str], +) -> HConfig: + driver = _get_driver(platform_or_driver) + config = get_hconfig(driver) + if isinstance(lines, str): + lines = lines.splitlines() + + current_section: Union[HConfig, HConfigChild] = config + most_recent_item: Union[HConfig, HConfigChild] = current_section + + for line in lines: + if not (line_lstripped := line.lstrip()): + continue + indent = len(line) - len(line_lstripped) + + # Determine parent in hierarchy + most_recent_item, current_section = _analyze_indent( + most_recent_item, + current_section, + indent, + " ".join(line.split()), + ) + + for child in tuple(config.all_children()): + child.delete_sectional_exit() + + return config + + +def _get_driver( + platform_or_driver: Union[Platform, HConfigDriverBase], +) -> HConfigDriverBase: + if isinstance(platform_or_driver, Platform): + return get_hconfig_driver(platform_or_driver) + return platform_or_driver + + +def _analyze_indent( + most_recent_item: Union[HConfig, HConfigChild], + current_section: Union[HConfig, HConfigChild], + indent: int, + line: str, +) -> tuple[HConfigChild, Union[HConfig, HConfigChild]]: + # Walks back up the tree + while indent <= current_section.real_indent_level: + current_section = current_section.parent + + # Walks down the tree by one step + if indent > most_recent_item.real_indent_level: + current_section = most_recent_item + + most_recent_item = current_section.add_child(line, raise_on_duplicate=True) + most_recent_item.real_indent_level = indent + + return most_recent_item, current_section + + +def _adjust_indent( + options: HConfigDriverBase, + line: str, + indent_adjust: int, + end_indent_adjust: list[str], +) -> tuple[int, list[str]]: + for expression in options.rules.indent_adjust: + if search(expression.start_expression, line): + return indent_adjust + 1, [*end_indent_adjust, expression.end_expression] + return indent_adjust, end_indent_adjust + + +def _config_from_string_lines_end_of_banner_test( + config_line: str, + banner_end_lines: frozenset[str], + banner_end_contains: list[str], +) -> bool: + if config_line.startswith("^"): + return True + if config_line in banner_end_lines: + return True + return any(c in config_line for c in banner_end_contains) + + +def _load_from_string_lines(config: HConfig, config_text: str) -> None: # noqa: C901 + if isinstance(config.driver, HConfigDriverJuniperJUNOS): + config_text = _convert_to_set_commands(config_text) + + current_section: Union[HConfig, HConfigChild] = config + most_recent_item: Union[HConfig, HConfigChild] = current_section + indent_adjust = 0 + end_indent_adjust: list[str] = [] + temp_banner: list[str] = [] + banner_end_lines = {"EOF", "%", "!"} + banner_end_contains: list[str] = [] + in_banner = False + + for line in config_text.splitlines(): + # Process banners in configuration into one line + if in_banner: + if line != "!": + temp_banner.append(line) + + # Test if this line is the end of a banner + if _config_from_string_lines_end_of_banner_test( + line, + frozenset(banner_end_lines), + banner_end_contains, + ): + in_banner = False + most_recent_item = config.add_child( + "\n".join(temp_banner), + raise_on_duplicate=True, + ) + most_recent_item.real_indent_level = 0 + current_section = config + temp_banner = [] + continue + + # Test if this line is the start of a banner and not an empty banner + # Empty banners matching the below expression have been seen on NX-OS + if line.startswith("banner ") and line != "banner motd ##": + in_banner = True + temp_banner.append(line) + banner_words = line.split() + with suppress(IndexError): + banner_end_contains.append(banner_words[2]) + # Handle banner on ArubaOS-Switch + if banner_words[2].startswith('"'): + banner_end_contains.append('"') + banner_end_lines.add(banner_words[2][:1]) + banner_end_lines.add(banner_words[2][:2]) + + continue + + actual_indent = len(line) - len(line.lstrip()) + line = " " * actual_indent + " ".join(line.split()) # noqa: PLW2901 + for rule in config.driver.rules.per_line_sub: + line = sub(rule.search, rule.replace, line) # noqa: PLW2901 + line = line.rstrip() # noqa: PLW2901 + + # If line is now empty, move to the next + if not line: + continue + + # Determine indentation level + this_indent = len(line) - len(line.lstrip()) + indent_adjust + + line = line.lstrip() # noqa: PLW2901 + + # Determine parent in hierarchy + most_recent_item, current_section = _analyze_indent( + most_recent_item, + current_section, + this_indent, + line, + ) + indent_adjust, end_indent_adjust = _adjust_indent( + config.driver, + line, + indent_adjust, + end_indent_adjust, + ) + + if end_indent_adjust and search(end_indent_adjust[0], line): + indent_adjust -= 1 + end_indent_adjust.pop(0) + if in_banner: + message = "we are still in a banner for some reason" + raise ValueError(message) + + +def _convert_to_set_commands(config_raw: str) -> str: + """Convert a Juniper style config string into a list of set commands. + + Args: + config_raw (str): The config string to convert to set commands + Returns: + config_raw (str): Configuration string + + """ + lines = config_raw.split("\n") + path: list[str] = [] + set_commands: list[str] = [] + + for line in lines: + stripped_line = line.strip() + + # Skip empty lines + if not stripped_line: + continue + + # Strip ; from the end of the line + if stripped_line.endswith(";"): + stripped_line = stripped_line.replace(";", "") + + # Count the number of spaces at the beginning to determine the level + level = line.find(stripped_line) // 4 + + # Adjust the current path based on the level + path = path[:level] + + # If the line ends with '{' or '}', it starts a new block + if stripped_line.endswith(("{", "}")): + path.append(stripped_line[:-1].strip()) + elif stripped_line.startswith(("set", "delete")): + # It's already a set command, so just add it to the list + set_commands.append(stripped_line) + else: + # It's a command line, construct the full command + command = f"set {' '.join(path)} {stripped_line}" + set_commands.append(command) + + return "\n".join(set_commands) diff --git a/hier_config/exceptions.py b/hier_config/exceptions.py new file mode 100644 index 0000000..82d93df --- /dev/null +++ b/hier_config/exceptions.py @@ -0,0 +1,2 @@ +class DuplicateChildError(Exception): + """Raised when attempting to add a duplicate child.""" diff --git a/hier_config/host.py b/hier_config/host.py deleted file mode 100644 index b99779d..0000000 --- a/hier_config/host.py +++ /dev/null @@ -1,182 +0,0 @@ -from functools import lru_cache -from typing import List, Set, Union, Optional - -import yaml - -from .root import HConfig -from .options import options_for - - -class Host: - """ - A host object is a convenient way to loading host inventory - items into a single object. - - The default is to load "hostname", "os", and "options" to the host object, - however, it can easily be extended for developer needs. - - .. code:: python - - import yaml - from hier_config.host import Host - - options = yaml.load(open("./tests/fixtures/options_ios.yml"), loader=yaml.SafeLoader()) - host = Host("example.rtr", "ios", options) - - # Example of loading running config and generated configs into a host object - host.load_running_config_from_file("./tests/files/running_config.conf) - host.load_generated_config_from_file("./tests/files/generated_config.conf) - - # Example of loading hier-config tags into a host object - host.load_tags("./tests/fixtures/tags_ios.yml") - - # Example of creating a remediation config without a tag targeting specific config - host.remediation_config() - - # Example of creating a remediation config with a tag ("safe") targeting a specific config. - host.remediation_config_filtered_text({"safe"}, set()}) - """ - - def __init__( # pylint: disable=dangerous-default-value - self, - hostname: str, - os: str, - hconfig_options: dict = {}, - ): - self.hostname = hostname - self.os = os - self.hconfig_options = ( - hconfig_options if hconfig_options else options_for(self.os) - ) - self._hconfig_tags: List[dict] = [] - self._running_config: Optional[HConfig] = None - self._generated_config: Optional[HConfig] = None - - def __repr__(self) -> str: - return f"Host(hostname={self.hostname})" - - @property - def running_config(self) -> Optional[HConfig]: - """running configuration property""" - if self._running_config is None: - self._running_config = self._get_running_config() - return self._running_config - - @property - def generated_config(self) -> Optional[HConfig]: - """generated configuration property""" - if self._generated_config is None: - self._generated_config = self._get_generated_config() - return self._generated_config - - @lru_cache() - def remediation_config(self) -> HConfig: - """ - Once self.running_config and self.generated_config have been created, - create self.remediation_config - """ - if self.running_config and self.generated_config: - remediation = self.running_config.config_to_get_to(self.generated_config) - else: - raise AttributeError("Missing host.running_config or host.generated_config") - - remediation.add_sectional_exiting() - remediation.set_order_weight() - remediation.add_tags(self.hconfig_tags) - - return remediation - - @lru_cache() - def rollback_config(self) -> HConfig: - """ - Once a self.running_config and self.generated_config have been created, - generate a self.rollback_config - """ - if self.running_config and self.generated_config: - rollback = self.generated_config.config_to_get_to(self.running_config) - else: - raise AttributeError("Missing host.running_config or host.generated_config") - - rollback.add_sectional_exiting() - rollback.set_order_weight() - rollback.add_tags(self.hconfig_tags) - - return rollback - - @property - def hconfig_tags(self) -> List[dict]: - """hier-config tags property""" - return self._hconfig_tags - - def load_running_config_from_file(self, file: str) -> None: - config = self._load_from_file(file) - if not isinstance(config, str): - raise TypeError - self.load_running_config(config) - - def load_running_config(self, config_text: str) -> None: - self._running_config = self._load_config(config_text) - - def load_generated_config_from_file(self, file: str) -> None: - config = self._load_from_file(file) - if not isinstance(config, str): - raise TypeError - self.load_generated_config(config) - - def load_generated_config(self, config_text: str) -> None: - self._generated_config = self._load_config(config_text) - - def remediation_config_filtered_text( - self, include_tags: Set[str], exclude_tags: Set[str] - ) -> str: - config = self.remediation_config() - if include_tags or exclude_tags: - children = config.all_children_sorted_by_tags(include_tags, exclude_tags) - else: - children = config.all_children_sorted() - - return "\n".join(c.cisco_style_text() for c in children) - - def load_tags(self, tags: list) -> None: - """ - Loads lineage rules that set tags - - Example: - Specify to load lineage rules from a dictionary. - - .. code:: python - - tags = [{"lineage": [{"startswith": "interface"}], "add_tags": "interfaces"}] - host.load_tags(tags) - - :param tags: tags - """ - self._hconfig_tags = tags - - def load_tags_from_file(self, file: str) -> None: - tags_from_file = self._load_from_file(file, True) - if not isinstance(tags_from_file, list): - raise TypeError - self.load_tags(tags_from_file) - - def _load_config(self, config_text: str) -> HConfig: - hier = HConfig(host=self) - hier.load_from_string(config_text) - return hier - - @staticmethod - def _load_from_file(name: str, parse_yaml: bool = False) -> Union[list, dict, str]: - """Opens a config file and loads it as a string.""" - with open(name) as file: # pylint: disable=unspecified-encoding - content = file.read() - - if parse_yaml: - content = yaml.safe_load(content) - - return content - - def _get_running_config(self) -> HConfig: - return NotImplemented - - def _get_generated_config(self) -> HConfig: - return NotImplemented diff --git a/hier_config/models.py b/hier_config/models.py new file mode 100644 index 0000000..fe1fd22 --- /dev/null +++ b/hier_config/models.py @@ -0,0 +1,111 @@ +from enum import Enum, auto +from typing import Optional, Union + +from pydantic import BaseModel as PydanticBaseModel +from pydantic import ConfigDict, NonNegativeInt, PositiveInt + + +class BaseModel(PydanticBaseModel): + """Pydantic.BaseModel with a safe config applied.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + +class DumpLine(BaseModel): + depth: NonNegativeInt + text: str + tags: frozenset[str] + comments: frozenset[str] + new_in_config: bool + + +class MatchRule(BaseModel): + equals: Union[str, frozenset[str], None] = None + startswith: Union[str, tuple[str, ...], None] = None + endswith: Union[str, tuple[str, ...], None] = None + contains: Union[str, tuple[str, ...], None] = None + re_search: Optional[str] = None + + +class TagRule(BaseModel): + match_rules: tuple[MatchRule, ...] + apply_tags: frozenset[str] + + +class SectionalExitingRule(BaseModel): + match_rules: tuple[MatchRule, ...] + exit_text: str + + +class SectionalOverwriteRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class SectionalOverwriteNoNegateRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class OrderingRule(BaseModel): + match_rules: tuple[MatchRule, ...] + weight: int + + +class IndentAdjustRule(BaseModel): + start_expression: str + end_expression: str + + +class ParentAllowsDuplicateChildRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class FullTextSubRule(BaseModel): + search: str + replace: str + + +class PerLineSubRule(BaseModel): + search: str + replace: str + + +class IdempotentCommandsRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class IdempotentCommandsAvoidRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class Instance(BaseModel): + id: PositiveInt + comments: frozenset[str] + tags: frozenset[str] + + +class NegationDefaultWhenRule(BaseModel): + match_rules: tuple[MatchRule, ...] + + +class NegationDefaultWithRule(BaseModel): + match_rules: tuple[MatchRule, ...] + use: str + + +SetLikeOfStr = Union[frozenset[str], set[str]] + + +class Platform(str, Enum): + ARISTA_EOS = auto() + CISCO_IOS = auto() + CISCO_NXOS = auto() + CISCO_XR = auto() + GENERIC = auto() # used in cases where the specific platform is unimportant/unknown + HP_COMWARE5 = auto() + HP_PROCURVE = auto() + JUNIPER_JUNOS = auto() + VYOS = auto() + + +class Dump(BaseModel): + lines: tuple[DumpLine, ...] diff --git a/hier_config/options.py b/hier_config/options.py deleted file mode 100644 index aefd0f4..0000000 --- a/hier_config/options.py +++ /dev/null @@ -1,715 +0,0 @@ -base_options: dict = { - "style": None, - "negation": "no", - "syntax_style": "cisco", - "sectional_overwrite": [], - "sectional_overwrite_no_negate": [], - "ordering": [], - "indent_adjust": [], - "parent_allows_duplicate_child": [], - "sectional_exiting": [], - "full_text_sub": [], - "per_line_sub": [], - "idempotent_commands_blacklist": [], - "idempotent_commands": [], - "negation_default_when": [], - "negation_negate_with": [], -} -ios_options: dict = { - "style": "ios", - "ordering": [ - {"lineage": [{"startswith": "no vlan filter"}], "order": 700}, - { - "lineage": [ - {"startswith": "interface"}, - {"startswith": "no shutdown"}, - ], - "order": 700, - }, - ], - "sectional_exiting": [ - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-policy"}, - ], - "exit_text": "exit-peer-policy", - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-session"}, - ], - "exit_text": "exit-peer-session", - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "address-family"}, - ], - "exit_text": "exit-address-family", - }, - ], - "per_line_sub": [ - {"search": "^Building configuration.*", "replace": ""}, - {"search": "^Current configuration.*", "replace": ""}, - {"search": "^! Last configuration change.*", "replace": ""}, - {"search": "^! NVRAM config last updated.*", "replace": ""}, - {"search": "^ntp clock-period .*", "replace": ""}, - {"search": "^version.*", "replace": ""}, - {"search": "^ logging event link-status$", "replace": ""}, - {"search": "^ logging event subif-link-status$", "replace": ""}, - {"search": "^\\s*ipv6 unreachables disable$", "replace": ""}, - {"search": "^end$", "replace": ""}, - {"search": "^\\s*[#!].*", "replace": ""}, - {"search": "^ no ip address", "replace": ""}, - {"search": "^ exit-peer-policy", "replace": ""}, - {"search": "^ exit-peer-session", "replace": ""}, - {"search": "^ exit-address-family", "replace": ""}, - {"search": "^crypto key generate rsa general-keys.*$", "replace": ""}, - ], - "idempotent_commands": [ - {"lineage": [{"startswith": "vlan"}, {"startswith": "name"}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": "description"}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": "ip address"}]}, - ], -} -iosxe_options: dict = { - "style": "ios", - "sectional_overwrite": [{"lineage": [{"startswith": "ipv6 access-list"}]}], - "sectional_exiting": [ - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-policy"}, - ], - "exit_text": "exit-peer-policy", - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-session"}, - ], - "exit_text": "exit-peer-session", - }, - { - "lineage": [{"startswith": "router bgp"}, {"startswith": "address-family"}], - "exit_text": "exit-address-family", - }, - ], - "per_line_sub": [ - {"search": "^Building configuration.*", "replace": ""}, - {"search": "^Current configuration.*", "replace": ""}, - {"search": "^! Last configuration change.*", "replace": ""}, - {"search": "^! NVRAM config last updated.*", "replace": ""}, - {"search": "^ntp clock-period .*", "replace": ""}, - {"search": "^version.*", "replace": ""}, - {"search": "^ logging event link-status$", "replace": ""}, - {"search": "^ logging event subif-link-status$", "replace": ""}, - {"search": "^\\s*ipv6 unreachables disable$", "replace": ""}, - {"search": "^end$", "replace": ""}, - {"search": "^ no ip address", "replace": ""}, - {"search": "^ exit-peer-policy", "replace": ""}, - {"search": "^ exit-peer-session", "replace": ""}, - {"search": "^ exit-address-family", "replace": ""}, - ], - "idempotent_commands": [ - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": ["log-adjacency-changes"]}, - ] - }, - {"lineage": [{"startswith": "router ospf"}, {"startswith": ["router-id"]}]}, - { - "lineage": [ - {"startswith": "ipv6 router ospf"}, - {"startswith": ["log-adjacency-changes"]}, - ] - }, - { - "lineage": [ - {"startswith": "ipv6 router ospf"}, - {"startswith": ["router-id"]}, - ] - }, - {"lineage": [{"startswith": "router bgp"}, {"startswith": "bgp router-id"}]}, - { - "lineage": [ - {"startswith": "router bgp"}, - {"re_search": "neighbor \\S+ description"}, - ] - }, - {"lineage": [{"startswith": ["hostname"]}]}, - {"lineage": [{"contains": ["source-interface", "trap-source"]}]}, - {"lineage": [{"startswith": ["snmp-server community"]}]}, - {"lineage": [{"startswith": ["mac address-table aging-time"]}]}, - {"lineage": [{"startswith": ["aaa authentication"]}]}, - {"lineage": [{"startswith": ["aaa authorization"]}]}, - {"lineage": [{"startswith": ["errdisable recovery"]}]}, - {"lineage": [{"startswith": "line"}, {"startswith": ["access-class"]}]}, - {"lineage": [{"startswith": "line"}, {"startswith": ["ipv6 access-class"]}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": ["ip ospf cost"]}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": ["ipv6 ospf cost"]}]}, - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": ["standby \\d authentication"]}, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": ["standby \\d priority"]}, - ] - }, - {"lineage": [{"startswith": "username admin "}]}, - { - "lineage": [ - {"startswith": "policy-map system-cpp-policy"}, - {"startswith": "class"}, - {"startswith": "police"}, - ] - }, - {"lineage": [{"startswith": "banner"}]}, - {"lineage": [{"startswith": "logging facility"}]}, - {"lineage": [{"startswith": "ip tftp source-interface"}]}, - {"lineage": [{"startswith": "snmp-server trap-source"}]}, - {"lineage": [{"startswith": "power redundancy-mode"}]}, - ], -} -iosxr_options: dict = { - "style": "iosxr", - "ordering": [ - {"lineage": [{"startswith": "vrf "}], "order": 300}, - {"lineage": [{"startswith": "no vrf "}], "order": 700}, - ], - "sectional_overwrite": [{"lineage": [{"startswith": "template"}]}], - "sectional_overwrite_no_negate": [ - {"lineage": [{"startswith": "as-path-set"}]}, - {"lineage": [{"startswith": "prefix-set"}]}, - {"lineage": [{"startswith": "route-policy"}]}, - {"lineage": [{"startswith": "extcommunity-set"}]}, - {"lineage": [{"startswith": "community-set"}]}, - ], - "parent_allows_duplicate_child": [{"lineage": [{"startswith": "route-policy"}]}], - "sectional_exiting": [ - {"lineage": [{"startswith": "route-policy"}], "exit_text": "end-policy"}, - {"lineage": [{"startswith": "prefix-set"}], "exit_text": "end-set"}, - {"lineage": [{"startswith": "policy-map"}], "exit_text": "end-policy-map"}, - {"lineage": [{"startswith": "class-map"}], "exit_text": "end-class-map"}, - {"lineage": [{"startswith": "community-set"}], "exit_text": "end-set"}, - {"lineage": [{"startswith": "extcommunity-set"}], "exit_text": "end-set"}, - {"lineage": [{"equals": "rsvp"}], "exit_text": "exit"}, - {"lineage": [{"equals": "mpls traffic-eng"}], "exit_text": "exit"}, - {"lineage": [{"startswith": "mpls ldp"}], "exit_text": "exit"}, - {"lineage": [{"startswith": "router ospf"}], "exit_text": "exit"}, - {"lineage": [{"startswith": "router ospfv3"}], "exit_text": "exit"}, - {"lineage": [{"startswith": "template"}], "exit_text": "end-template"}, - {"lineage": [{"startswith": "interface"}], "exit_text": "root"}, - {"lineage": [{"startswith": "router bgp"}], "exit_text": "root"}, - ], - "indent_adjust": [ - {"start_expression": "^\\s*template", "end_expression": "^\\s*end-template"} - ], - "per_line_sub": [ - {"search": "^Building configuration.*", "replace": ""}, - {"search": "^Current configuration.*", "replace": ""}, - {"search": "^ntp clock-period .*", "replace": ""}, - {"search": ".*speed.*", "replace": ""}, - {"search": ".*duplex.*", "replace": ""}, - {"search": ".*negotiation auto.*", "replace": ""}, - {"search": ".*parity none.*", "replace": ""}, - {"search": "^end-policy$", "replace": " end-policy"}, - {"search": "^end-set$", "replace": " end-set"}, - {"search": "^end$", "replace": ""}, - {"search": "^\\s*[#!].*", "replace": ""}, - ], - "idempotent_commands": [ - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "address-family"}, - {"startswith": "additional-paths selection route-policy"}, - ] - }, - {"lineage": [{"startswith": "router bgp"}, {"startswith": "bgp router-id"}]}, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "neighbor-group"}, - {"startswith": "address-family"}, - {"startswith": "soft-reconfiguration inbound"}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "neighbor"}, - {"startswith": "address-family"}, - {"startswith": ["soft-reconfiguration inbound", "maximum-prefix"]}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "neighbor"}, - {"startswith": ["password", "description"]}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "neighbor"}, - {"startswith": ["description", "password"]}, - ] - }, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": "area"}, - {"startswith": "interface"}, - {"startswith": "cost"}, - ] - }, - {"lineage": [{"startswith": "router ospf"}, {"startswith": "router-id"}]}, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": "area"}, - {"startswith": "message-digest-key"}, - ] - }, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": "max-metric router-lsa"}, - ] - }, - {"lineage": [{"equals": "l2vpn"}, {"startswith": "router-id"}]}, - {"lineage": [{"re_search": "logging \\d+.\\d+.\\d+.\\d+ vrf MGMT"}]}, - { - "lineage": [ - {"equals": "line default"}, - {"startswith": "access-class ingress"}, - ] - }, - {"lineage": [{"equals": "line default"}, {"startswith": "transport input"}]}, - {"lineage": [{"startswith": "hostname"}]}, - {"lineage": [{"startswith": "logging source-interface"}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": "ipv4 address"}]}, - {"lineage": [{"startswith": "snmp-server community"}]}, - {"lineage": [{"startswith": "snmp-server location"}]}, - {"lineage": [{"equals": "line console"}, {"startswith": "exec-timeout"}]}, - { - "lineage": [ - {"equals": "mpls ldp"}, - {"startswith": "session protection duration"}, - ] - }, - {"lineage": [{"equals": "mpls ldp"}, {"startswith": "igp sync delay"}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": ["mtu"]}]}, - {"lineage": [{"startswith": "banner"}]}, - ], -} -nxos_options: dict = { - "style": "nxos", - "per_line_sub": [ - {"search": "^Building configuration.*", "replace": ""}, - {"search": "^Current configuration.*", "replace": ""}, - {"search": "^ntp clock-period .*", "replace": ""}, - {"search": "^snmp-server location ", "replace": "snmp-server location "}, - {"search": "^version.*", "replace": ""}, - {"search": "^boot (system|kickstart) .*", "replace": ""}, - {"search": "!.*", "replace": ""}, - ], - "idempotent_commands_blacklist": [ - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": "ip address.*secondary"}, - ] - } - ], - "idempotent_commands": [ - { - "lineage": [ - { - "startswith": [ - "power redundancy-mode", - "cli alias name wr ", - "aaa authentication login console", - "port-channel load-balance", - "hostname", - "ip tftp source-interface", - "ip telnet source-interface", - "ip tacacs source-interface", - "logging source-interface", - ], - "re_search": "^spanning-tree vlan ([\\d,-]+) priority", - } - ] - }, - {"lineage": [{"startswith": ["hardware access-list tcam region ifacl"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region vacl"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region qos"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region racl"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region ipv6-racl"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region e-ipv6-racl"]}]}, - {"lineage": [{"startswith": ["hardware access-list tcam region l3qos"]}]}, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": "vrf"}, - {"startswith": ["maximum-paths", "log-adjacency-changes"]}, - ] - }, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": ["maximum-paths", "log-adjacency-changes"]}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "address-family"}, - {"startswith": ["maximum-paths"]}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "address-family"}, - {"startswith": ["maximum-paths"]}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template"}, - {"startswith": "address-family"}, - {"startswith": "send-community"}, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": "^hsrp \\d+"}, - {"startswith": ["ip", "priority", "authentication md5 key-string"]}, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - { - "startswith": [ - "ip address", - "duplex", - "speed", - "switchport mode", - "switchport access vlan", - "switchport trunk native vlan", - "switchport trunk allowed vlan", - "udld port", - "ip ospf cost", - "ipv6 link-local", - "ospfv3 cost", - ] - }, - ] - }, - {"lineage": [{"startswith": "interface"}, {"startswith": "mtu"}]}, - {"lineage": [{"equals": "line console"}, {"startswith": "exec-timeout"}]}, - { - "lineage": [ - {"startswith": "line vty"}, - { - "startswith": [ - "transport input", - "ipv6 access-class", - "access-class", - ] - }, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - { - "startswith": "bgp router-id", - "re_search": "neighbor \\S+ description", - }, - ] - }, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": ["router-id", "log-adjacency-changes"]}, - ] - }, - { - "lineage": [ - {"startswith": "ipv6 router ospf"}, - {"startswith": ["router-id", "log-adjacency-changes"]}, - ] - }, - { - "lineage": [ - { - "startswith": [ - "mac address-table aging-time", - "snmp-server community", - "snmp-server location", - ] - } - ] - }, - {"lineage": [{"startswith": "vpc domain"}, {"startswith": "role priority"}]}, - {"lineage": [{"startswith": "banner"}]}, - {"lineage": [{"startswith": "username admin password 5"}]}, - { - "lineage": [ - {"equals": "policy-map type control-plane copp-system-policy"}, - {"startswith": "class"}, - {"startswith": "police"}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "neighbor"}, - {"startswith": "address-family"}, - {"startswith": "soft-reconfiguration inbound"}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "neighbor"}, - {"startswith": "password"}, - ] - }, - ], - "negation_default_when": [ - { - "lineage": [ - {"startswith": "interface"}, - { - "startswith": "ip ospf bfd", - "re_search": "standby \\d+ authentication md5 key-string", - }, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "neighbor"}, - {"startswith": "address-family"}, - {"equals": "send-community"}, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - {"contains": "ip ospf passive-interface"}, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - {"contains": "ospfv3 passive-interface"}, - ] - }, - ], - "negation_negate_with": [ - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "address-family"}, - {"startswith": "maximum-paths ibgp"}, - ], - "use": "default maximum-paths ibgp", - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "vrf"}, - {"startswith": "address-family"}, - {"startswith": "maximum-paths ibgp"}, - ], - "use": "default maximum-paths ibgp", - }, - { - "lineage": [{"equals": "line vty"}, {"startswith": "session-limit"}], - "use": "session-limit 32", - }, - ], -} -eos_options: dict = { - "style": "eos", - "sectional_exiting": [ - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-policy"}, - ], - "exit_text": "exit-peer-policy", - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"startswith": "template peer-session"}, - ], - "exit_text": "exit-peer-session", - }, - { - "lineage": [{"startswith": "router bgp"}, {"startswith": "address-family"}], - "exit_text": "exit-address-family", - }, - ], - "per_line_sub": [ - {"search": "^Building configuration.*", "replace": ""}, - {"search": "^Current configuration.*", "replace": ""}, - {"search": "^! Last configuration change.*", "replace": ""}, - {"search": "^! NVRAM config last updated.*", "replace": ""}, - {"search": "^ntp clock-period .*", "replace": ""}, - {"search": "^version.*", "replace": ""}, - {"search": "^ logging event link-status$", "replace": ""}, - {"search": "^ logging event subif-link-status$", "replace": ""}, - {"search": "^\\s*ipv6 unreachables disable$", "replace": ""}, - {"search": "^end$", "replace": ""}, - {"search": "^\\s*[#!].*", "replace": ""}, - {"search": "^ no ip address", "replace": ""}, - {"search": "^ exit-peer-policy", "replace": ""}, - {"search": "^ exit-peer-session", "replace": ""}, - {"search": "^ exit-address-family", "replace": ""}, - ], - "idempotent_commands": [ - {"lineage": [{"startswith": "hostname"}]}, - {"lineage": [{"startswith": "logging source-interface"}]}, - {"lineage": [{"startswith": "interface"}, {"startswith": "ip address"}]}, - { - "lineage": [ - {"startswith": "line vty"}, - { - "startswith": [ - "transport input", - "access-class", - "ipv6 access-class", - ] - }, - ] - }, - { - "lineage": [ - {"startswith": "interface"}, - {"re_search": "standby \\d+ (priority|authentication md5)"}, - ] - }, - {"lineage": [{"startswith": "router bgp"}, {"startswith": "bgp router-id"}]}, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": ["router-id", "max-lsa", "maximum-paths"]}, - ] - }, - {"lineage": [{"startswith": "ipv6 router ospf"}, {"startswith": "router-id"}]}, - { - "lineage": [ - {"startswith": "router ospf"}, - {"startswith": "log-adjacency-changes"}, - ] - }, - { - "lineage": [ - {"startswith": "ipv6 router ospf"}, - {"startswith": "log-adjacency-changes"}, - ] - }, - { - "lineage": [ - {"startswith": "router bgp"}, - {"re_search": "neighbor \\S+ description"}, - ] - }, - {"lineage": [{"startswith": "snmp-server community"}]}, - {"lineage": [{"startswith": "snmp-server location"}]}, - {"lineage": [{"equals": "line con 0"}, {"startswith": "exec-timeout"}]}, - { - "lineage": [ - {"startswith": "interface"}, - {"startswith": "ip ospf message-digest-key"}, - ] - }, - {"lineage": [{"startswith": "logging buffered"}]}, - {"lineage": [{"startswith": "tacacs-server key"}]}, - {"lineage": [{"startswith": "logging facility"}]}, - {"lineage": [{"startswith": "vlan internal allocation policy"}]}, - {"lineage": [{"startswith": "username admin"}]}, - {"lineage": [{"startswith": "snmp-server user"}]}, - {"lineage": [{"startswith": "banner"}]}, - {"lineage": [{"startswith": "ntp source"}]}, - {"lineage": [{"startswith": "management"}, {"startswith": "idle-timeout"}]}, - { - "lineage": [ - {"startswith": "aaa authentication enable default group tacacs+"} - ] - }, - { - "lineage": [ - {"equals": "control-plane"}, - {"equals": "ip access-group CPP in"}, - ] - }, - {"lineage": [{"startswith": "interface"}, {"startswith": "mtu"}]}, - {"lineage": [{"startswith": "snmp-server source-interface"}]}, - {"lineage": [{"startswith": "ip tftp client source-interface"}]}, - ], - "negation_default_when": [ - { - "lineage": [ - {"startswith": "interface"}, - {"equals": "logging event link-status"}, - ] - } - ], -} - - -junos_options: dict = { - "style": "junos", - "negation": "delete", - "syntax_style": "juniper", -} - - -vyos_options: dict = { - "style": "vyos", - "negation": "delete", - "syntax_style": "juniper", -} - - -def options_for(os: str) -> dict: - """Create base options on an OS level.""" - options: dict = { - "ios": ios_options, - "iosxe": iosxe_options, - "iosxr": iosxr_options, - "nxos": nxos_options, - "eos": eos_options, - "junos": junos_options, - "vyos": vyos_options, - } - - if options.get(os): - return {**base_options, **options[os]} - - return {**base_options, "style": os} diff --git a/hier_config/platforms/__init__.py b/hier_config/platforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/arista_eos/__init__.py b/hier_config/platforms/arista_eos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/arista_eos/driver.py b/hier_config/platforms/arista_eos/driver.py new file mode 100644 index 0000000..f5a8406 --- /dev/null +++ b/hier_config/platforms/arista_eos/driver.py @@ -0,0 +1,234 @@ +from hier_config.models import ( + IdempotentCommandsRule, + MatchRule, + NegationDefaultWhenRule, + PerLineSubRule, + SectionalExitingRule, +) +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverAristaEOS(HConfigDriverBase): + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + sectional_exiting=[ + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="template peer-policy"), + ), + exit_text="exit-peer-policy", + ), + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="template peer-session"), + ), + exit_text="exit-peer-session", + ), + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="address-family"), + ), + exit_text="exit-address-family", + ), + ], + per_line_sub=[ + PerLineSubRule(search="^Building configuration.*", replace=""), + PerLineSubRule(search="^Current configuration.*", replace=""), + PerLineSubRule(search="^! Last configuration change.*", replace=""), + PerLineSubRule(search="^! NVRAM config last updated.*", replace=""), + PerLineSubRule(search="^ntp clock-period .*", replace=""), + PerLineSubRule(search="^version.*", replace=""), + PerLineSubRule(search="^ logging event link-status$", replace=""), + PerLineSubRule(search="^ logging event subif-link-status$", replace=""), + PerLineSubRule(search="^\\s*ipv6 unreachables disable$", replace=""), + PerLineSubRule(search="^end$", replace=""), + PerLineSubRule(search="^\\s*[#!].*", replace=""), + PerLineSubRule(search="^ no ip address", replace=""), + PerLineSubRule(search="^ exit-peer-policy", replace=""), + PerLineSubRule(search="^ exit-peer-session", replace=""), + PerLineSubRule(search="^ exit-address-family", replace=""), + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="hostname"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="logging source-interface"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ip address"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule( + startswith="transport input", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule( + startswith="access-class", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule( + startswith="ipv6 access-class", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule( + re_search="standby \\d+ (priority|authentication md5)", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="bgp router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="max-lsa"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="maximum-paths"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="ipv6 router ospf"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="ipv6 router ospf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(re_search="neighbor \\S+ description"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server community"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server location"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="line con 0"), + MatchRule(startswith="exec-timeout"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ip ospf message-digest-key"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="logging buffered"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="tacacs-server key"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="logging facility"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="vlan internal allocation policy"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="username admin"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server user"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="banner"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="ntp source"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="management"), + MatchRule(startswith="idle-timeout"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule( + startswith="aaa authentication enable default group tacacs+" + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="control-plane"), + MatchRule(equals="ip access-group CPP in"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="mtu"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server source-interface"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="ip tftp client source-interface"), + ), + ), + ], + negation_default_when=[ + NegationDefaultWhenRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(equals="logging event link-status"), + ), + ), + ], + ) diff --git a/hier_config/platforms/arista_eos/view.py b/hier_config/platforms/arista_eos/view.py new file mode 100644 index 0000000..1e8507b --- /dev/null +++ b/hier_config/platforms/arista_eos/view.py @@ -0,0 +1,191 @@ +from collections.abc import Iterable +from ipaddress import IPv4Address, IPv4Interface +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, +) + + +class ConfigViewInterfaceAristaEOS(ConfigViewInterfaceBase): # noqa: PLR0904 + @property + def bundle_id(self) -> Optional[str]: + raise NotImplementedError + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + raise NotImplementedError + + @property + def bundle_name(self) -> Optional[str]: + raise NotImplementedError + + @property + def description(self) -> str: + raise NotImplementedError + + @property + def duplex(self) -> InterfaceDuplex: + raise NotImplementedError + + @property + def enabled(self) -> bool: + raise NotImplementedError + + @property + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" + raise NotImplementedError + + @property + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + raise NotImplementedError + + @property + def is_bundle(self) -> bool: + raise NotImplementedError + + @property + def is_loopback(self) -> bool: + raise NotImplementedError + + @property + def is_subinterface(self) -> bool: + return "." in self.name + + @property + def is_svi(self) -> bool: + raise NotImplementedError + + @property + def module_number(self) -> Optional[int]: + raise NotImplementedError + + @property + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC control direction in configured.""" + raise NotImplementedError + + @property + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + raise NotImplementedError + + @property + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + raise NotImplementedError + + @property + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" + raise NotImplementedError + + @property + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + raise NotImplementedError + + @property + def name(self) -> str: + raise NotImplementedError + + @property + def native_vlan(self) -> Optional[int]: + raise NotImplementedError + + @property + def number(self) -> str: + raise NotImplementedError + + @property + def parent_name(self) -> Optional[str]: + raise NotImplementedError + + @property + def poe(self) -> bool: + raise NotImplementedError + + @property + def port_number(self) -> int: + return int(self.name.split("/")[-1].split(".")[0]) + + @property + def speed(self) -> Optional[tuple[int, ...]]: + raise NotImplementedError + + @property + def subinterface_number(self) -> Optional[int]: + raise NotImplementedError + + @property + def tagged_all(self) -> bool: + raise NotImplementedError + + @property + def tagged_vlans(self) -> tuple[int, ...]: + raise NotImplementedError + + @property + def vrf(self) -> str: + raise NotImplementedError + + @property + def _bundle_prefix(self) -> str: + raise NotImplementedError + + +class HConfigViewAristaEOS(HConfigViewBase): + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + raise NotImplementedError + + @property + def hostname(self) -> Optional[str]: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower() + return None + + @property + def interface_names_mentioned(self) -> frozenset[str]: + """Returns a set with all the interface names mentioned in the config.""" + raise NotImplementedError + + @property + def interface_views(self) -> Iterable[ConfigViewInterfaceAristaEOS]: + for interface in self.interfaces: + yield ConfigViewInterfaceAristaEOS(interface) + + @property + def interfaces(self) -> Iterable[HConfigChild]: + return self.config.get_children(startswith="interface ") + + @property + def ipv4_default_gw(self) -> Optional[IPv4Address]: + raise NotImplementedError + + @property + def location(self) -> str: + raise NotImplementedError + + @property + def stack_members(self) -> Iterable[StackMember]: + raise NotImplementedError + + @property + def vlans(self) -> Iterable[Vlan]: + raise NotImplementedError diff --git a/hier_config/platforms/cisco_ios/__init__.py b/hier_config/platforms/cisco_ios/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/cisco_ios/driver.py b/hier_config/platforms/cisco_ios/driver.py new file mode 100644 index 0000000..fd0d0df --- /dev/null +++ b/hier_config/platforms/cisco_ios/driver.py @@ -0,0 +1,182 @@ +from logging import getLogger + +from hier_config.models import ( + IdempotentCommandsRule, + MatchRule, + NegationDefaultWithRule, + OrderingRule, + PerLineSubRule, + SectionalExitingRule, +) +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules +from hier_config.root import HConfig + +logger = getLogger(__name__) + + +def _rm_ipv6_acl_sequence_numbers(config: HConfig) -> None: + """If there are sequence numbers in the IPv6 ACL, remove them.""" + for acl in config.get_children(startswith="ipv6 access-list "): + for entry in acl.children: + if entry.text.startswith("sequence"): + entry.text = " ".join(entry.text.split()[2:]) + + +def _remove_ipv4_acl_remarks(config: HConfig) -> None: + for acl in config.get_children(startswith="ip access-list "): + for entry in tuple(acl.children): + if entry.text.startswith("remark"): + acl.children.remove(entry) + + +def _add_acl_sequence_numbers(config: HConfig) -> None: + """Add ACL sequence numbers.""" + ipv4_acl_sw = "ip access-list" + acl_line_sw: tuple[str, ...] = ("permit", "deny") + for child in config.children: + if child.text.startswith(ipv4_acl_sw): + sequence_number = 10 + for sub_child in child.children: + if sub_child.text.startswith(acl_line_sw): + sub_child.text = f"{sequence_number} {sub_child.text}" + sequence_number += 10 + + +class HConfigDriverCiscoIOS(HConfigDriverBase): + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + negate_with=[ + NegationDefaultWithRule( + match_rules=(MatchRule(startswith="logging console "),), + use="logging console debugging", + ), + ], + sectional_exiting=[ + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="template peer-policy"), + ), + exit_text="exit-peer-policy", + ), + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="template peer-session"), + ), + exit_text="exit-peer-session", + ), + SectionalExitingRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="address-family"), + ), + exit_text="exit-address-family", + ), + ], + ordering=[ + OrderingRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="switchport mode "), + ), + weight=-10, + ), + OrderingRule( + match_rules=(MatchRule(startswith="no vlan filter"),), + weight=200, + ), + OrderingRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="no shutdown"), + ), + weight=200, + ), + OrderingRule( + match_rules=( + MatchRule(startswith="aaa group server tacacs+ "), + MatchRule(startswith="no server "), + ), + weight=10, + ), + OrderingRule( + match_rules=(MatchRule(startswith="no tacacs-server "),), + weight=10, + ), + ], + per_line_sub=[ + PerLineSubRule(search="^Building configuration.*", replace=""), + PerLineSubRule(search="^Current configuration.*", replace=""), + PerLineSubRule(search="^! Last configuration change.*", replace=""), + PerLineSubRule(search="^! NVRAM config last updated.*", replace=""), + PerLineSubRule(search="^ntp clock-period .*", replace=""), + PerLineSubRule(search="^version.*", replace=""), + PerLineSubRule(search="^ logging event link-status$", replace=""), + PerLineSubRule(search="^ logging event subif-link-status$", replace=""), + PerLineSubRule(search="^\\s*ipv6 unreachables disable$", replace=""), + PerLineSubRule(search="^end$", replace=""), + PerLineSubRule(search="^\\s*[#!].*", replace=""), + PerLineSubRule(search="^ no ip address", replace=""), + PerLineSubRule(search="^ exit-peer-policy", replace=""), + PerLineSubRule(search="^ exit-peer-session", replace=""), + PerLineSubRule(search="^ exit-address-family", replace=""), + PerLineSubRule( + search="^crypto key generate rsa general-keys.*$", replace="" + ), + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="vlan"), + MatchRule(startswith="name"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="description "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="ip address "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="switchport mode "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="authentication host-mode "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule( + startswith="authentication event server dead action authorize vlan ", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="errdisable recovery interval "), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(re_search=r"^(no )?logging console.*"),), + ), + ], + post_load_callbacks=[ + _rm_ipv6_acl_sequence_numbers, + _remove_ipv4_acl_remarks, + _add_acl_sequence_numbers, + ], + ) diff --git a/hier_config/platforms/cisco_ios/view.py b/hier_config/platforms/cisco_ios/view.py new file mode 100644 index 0000000..ff9bd0f --- /dev/null +++ b/hier_config/platforms/cisco_ios/view.py @@ -0,0 +1,320 @@ +from collections.abc import Iterable +from ipaddress import AddressValueError, IPv4Address, IPv4Interface +from re import sub +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.functions import expand_range +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, +) + + +class ConfigViewInterfaceCiscoIOS(ConfigViewInterfaceBase): # noqa: PLR0904 + @property + def bundle_id(self) -> Optional[str]: + if channel_group := self.config.get_child(startswith="channel-group"): + return channel_group.text.split()[1] + return None + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + raise NotImplementedError + + @property + def bundle_name(self) -> Optional[str]: + if self.bundle_id: + return f"{self._bundle_prefix}{self.bundle_id}" + return None + + @property + def description(self) -> str: + if child := self.config.get_child(startswith="description "): + return child.text.split(maxsplit=1)[1] + return "" + + @property + def duplex(self) -> InterfaceDuplex: + if duplex := self.config.get_child(startswith="duplex "): + return InterfaceDuplex(duplex.text.split()[1]) + return InterfaceDuplex.AUTO + + @property + def enabled(self) -> bool: + return not self.config.get_child(equals="shutdown") + + @property + def has_nac(self) -> bool: + return any( + line in self.config.children_dict + for line in ( + "authentication port-control auto", + "mab", + ) + ) + + @property + def ipv4_interface(self) -> Optional[IPv4Interface]: + return next(iter(self.ipv4_interfaces), None) + + @property + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + for ipv4_address_obj in self.config.get_children(startswith="ip address "): + ipv4_address = ipv4_address_obj.text.split() + try: + yield IPv4Interface("/".join(ipv4_address[2:4])) + except AddressValueError: + continue + + @property + def is_bundle(self) -> bool: + return self.name.lower().startswith(self._bundle_prefix) + + @property + def is_loopback(self) -> bool: + return self.name.lower().startswith("loopback") + + @property + def is_subinterface(self) -> bool: + return "." in self.name + + @property + def is_svi(self) -> bool: + return self.name.lower().startswith("vlan") + + @property + def module_number(self) -> Optional[int]: + words = self.number.split("/", 1) + if len(words) == 1: + return None + return int(words[0]) + + @property + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC control direction in configured.""" + return "authentication control-direction in" in self.config.children_dict + + @property + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + mode = self.config.get_child(startswith="authentication host-mode ") + if mode is None: + return None + + mode_word = mode.text.split()[2] + if mode_word == "multi-auth": + return NACHostMode.MULTI_AUTH + if mode_word == "multi-domain": + return NACHostMode.MULTI_DOMAIN + if mode_word == "multi-host": + return NACHostMode.MULTI_HOST + if mode_word == "single-host": + return NACHostMode.SINGLE_HOST + + message = f"Unhandled NAC host mode: {mode_word}" + raise ValueError(message) + + @property + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + return bool(self.config.get_child(equals="authentication order mab dot1x")) + + @property + def nac_max_dot1x_clients(self) -> int: + """Determine the max mab clients.""" + raise NotImplementedError + + @property + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + raise NotImplementedError + + @property + def name(self) -> str: + return self.config.text.split()[1] + + @property + def native_vlan(self) -> Optional[int]: + # It's configured as a sub-interface + if self.is_subinterface and ( + vlan := self.config.get_child(startswith="encapsulation dot1Q ") + ): + return int(vlan.text.split()[2]) + + # It's not a switchport + if ( + self.config.get_child(equals="no switchport") + or self.config.get_child(startswith="ip address ") + or self.is_loopback + or self.is_svi + ): + return None + + # It's configured as a trunk + if self.config.get_child(equals="switchport mode trunk"): + if vlan := self.config.get_child( + startswith="switchport trunk native vlan ", + ): + return int(vlan.text.split()[4]) + + return None + + # It's either dynamic or configured as an access port + if vlan := self.config.get_child(startswith="switchport access vlan "): + return int(vlan.text.split()[3]) + + # Default VLAN + return 1 + + @property + def number(self) -> str: + return sub("^[a-zA-Z-]+", "", self.name) + + @property + def parent_name(self) -> Optional[str]: + if self.is_subinterface: + return self.name.split(".")[0] + return None + + @property + def poe(self) -> bool: + return not self.config.get_child(equals="power inline never") + + @property + def port_number(self) -> int: + return int(self.name.split("/")[-1].split(".")[0]) + + @property + def speed(self) -> Optional[tuple[int, ...]]: + if speed := self.config.get_child(startswith="speed "): + if speed.text == "auto": + return None + return (int(speed.text.split()[1]),) + return None + + @property + def subinterface_number(self) -> Optional[int]: + return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None + + @property + def tagged_all(self) -> bool: + return bool( + self.config.get_child(equals="switchport mode trunk") + and not self.tagged_vlans, + ) + + @property + def tagged_vlans(self) -> tuple[int, ...]: + if child := self.config.get_child( + re_search="^switchport trunk allowed vlan [0-9,-]+$", + ): + return expand_range(child.text.split()[4]) + return () + + @property + def vrf(self) -> str: + if vrf := self.config.get_child(startswith="ip vrf forwarding "): + return vrf.text.split()[3] + return "" + + @property + def _bundle_prefix(self) -> str: + return "Port-channel" + + +class HConfigViewCiscoIOS(HConfigViewBase): + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + raise NotImplementedError + + @property + def hostname(self) -> Optional[str]: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower() + return None + + @property + def interface_names_mentioned(self) -> frozenset[str]: + """Returns a set with all the interface names mentioned in the config.""" + return frozenset(model.name for model in self.interface_views) + + @property + def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoIOS]: + for interface in self.interfaces: + yield ConfigViewInterfaceCiscoIOS(interface) + + @property + def interfaces(self) -> Iterable[HConfigChild]: + return self.config.get_children(startswith="interface ") + + @property + def ipv4_default_gw(self) -> Optional[IPv4Address]: + if gateway := self.config.get_child(startswith="ip default-gateway "): + return IPv4Address(gateway.text.split()[2]) + return None + + @property + def location(self) -> str: + if location := self.config.get_child(startswith="snmp-server location "): + return location.text.split(maxsplit=2)[2].replace('"', "") + return "" + + @property + def stack_members(self) -> Iterable[StackMember]: + """stacking + member 1 type "JL123" mac-address abc123-abc123 + member 1 priority 255 + member 2 type "JL123" mac-address abc123-abc123 + member 2 priority 254 + ... + """ + for member in self.config.get_children(re_search="^switch .* provision .*"): + words = member.text.split() + member_id = int(words[1]) + yield StackMember( + id=member_id, + priority=256 - member_id, + mac_address=None, + model=words[3], + ) + + @property + def vlans(self) -> Iterable[Vlan]: + yielded_vlans: set[int] = set() + + # Yield explicitly defined VLANs + for child in self.config.get_children(re_search="^vlan [0-9,-]+$"): + vlan_name = None + if name := child.get_child(startswith="name "): + _, vlan_name = name.text.split(maxsplit=1) + vlan_name = vlan_name.replace('"', "") + for vlan_id in expand_range(child.text.split()[1]): + yielded_vlans.add(vlan_id) + yield Vlan( + id=vlan_id, + name=vlan_name or None, + ) + + # Yield any remaining unnamed VLANs mentioned on interfaces + for interface_view in self.interface_views: + if ( + native_vlan := interface_view.native_vlan + ) and native_vlan not in yielded_vlans: + yielded_vlans.add(native_vlan) + yield Vlan( + id=native_vlan, + name=None, + ) diff --git a/hier_config/platforms/cisco_nxos/__init__.py b/hier_config/platforms/cisco_nxos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/cisco_nxos/driver.py b/hier_config/platforms/cisco_nxos/driver.py new file mode 100644 index 0000000..82b1a28 --- /dev/null +++ b/hier_config/platforms/cisco_nxos/driver.py @@ -0,0 +1,414 @@ +from hier_config.models import ( + IdempotentCommandsAvoidRule, + IdempotentCommandsRule, + MatchRule, + NegationDefaultWhenRule, + NegationDefaultWithRule, + PerLineSubRule, +) +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverCiscoNXOS(HConfigDriverBase): + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + per_line_sub=[ + PerLineSubRule(search="^Building configuration.*", replace=""), + PerLineSubRule(search="^Current configuration.*", replace=""), + PerLineSubRule(search="^ntp clock-period .*", replace=""), + PerLineSubRule( + search="^snmp-server location ", + replace="snmp-server location ", + ), + PerLineSubRule(search="^version.*", replace=""), + PerLineSubRule(search="^boot (system|kickstart) .*", replace=""), + PerLineSubRule(search="!.*", replace=""), + ], + idempotent_commands_avoid=[ + IdempotentCommandsAvoidRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(re_search="ip address.*secondary"), + ), + ), + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="power redundancy-mode"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="cli alias name wr "),) + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="aaa authentication login console"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="port-channel load-balance"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="hostname "),) + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="ip tftp source-interface"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="ip telnet source-interface"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="ip tacacs source-interface"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="logging source-interface"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="hardware access-list tcam region ifacl"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="hardware access-list tcam region vacl"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="hardware access-list tcam region qos"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="hardware access-list tcam region racl"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule( + startswith="hardware access-list tcam region ipv6-racl" + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule( + startswith="hardware access-list tcam region e-ipv6-racl" + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="hardware access-list tcam region l3qos"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="vrf"), + MatchRule(startswith="maximum-paths"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="vrf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="maximum-paths"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="address-family"), + MatchRule(startswith="maximum-paths"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="address-family"), + MatchRule(startswith="maximum-paths"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="template"), + MatchRule(startswith="address-family"), + MatchRule(startswith="send-community"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(re_search="^hsrp \\d+"), + MatchRule(startswith="ip"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(re_search="^hsrp \\d+"), + MatchRule(startswith="priority"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(re_search="^hsrp \\d+"), + MatchRule(startswith="authentication md5 key-string"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ip address"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="duplex"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="speed"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="switchport mode"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="switchport access vlan"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="switchport trunk native vlan"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="switchport trunk allowed vlan"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="udld port"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ip ospf cost"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ipv6 link-local"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ospfv3 cost"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="mtu"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="line console"), + MatchRule(startswith="exec-timeout"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule(startswith="transport input"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule(startswith="ipv6 access-class"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="line vty"), + MatchRule(startswith="access-class"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule( + startswith="bgp router-id", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule( + re_search="neighbor \\S+ description", + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="ipv6 router ospf"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="ipv6 router ospf"), + MatchRule(startswith="log-adjacency-changes"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="mac address-table aging-time"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server community"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server location"),) + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="vpc domain"), + MatchRule(startswith="role priority"), + ), + ), + IdempotentCommandsRule(match_rules=(MatchRule(startswith="banner"),)), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="username admin password 5"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule( + equals="policy-map type control-plane copp-system-policy" + ), + MatchRule(startswith="class"), + MatchRule(startswith="police"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="address-family"), + MatchRule(startswith="soft-reconfiguration inbound"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="password"), + ), + ), + ], + negation_default_when=[ + NegationDefaultWhenRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule( + startswith="ip ospf bfd", + re_search="standby \\d+ authentication md5 key-string", + ), + ), + ), + NegationDefaultWhenRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="address-family"), + MatchRule(equals="send-community"), + ), + ), + NegationDefaultWhenRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(contains="ip ospf passive-interface"), + ), + ), + NegationDefaultWhenRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(contains="ospfv3 passive-interface"), + ), + ), + ], + negate_with=[ + NegationDefaultWithRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="address-family"), + MatchRule(startswith="maximum-paths ibgp"), + ), + use="default maximum-paths ibgp", + ), + NegationDefaultWithRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="address-family"), + MatchRule(startswith="maximum-paths ibgp"), + ), + use="default maximum-paths ibgp", + ), + NegationDefaultWithRule( + match_rules=( + MatchRule(equals="line vty"), + MatchRule(startswith="session-limit"), + ), + use="session-limit 32", + ), + ], + ) diff --git a/hier_config/platforms/cisco_nxos/view.py b/hier_config/platforms/cisco_nxos/view.py new file mode 100644 index 0000000..14ce700 --- /dev/null +++ b/hier_config/platforms/cisco_nxos/view.py @@ -0,0 +1,208 @@ +from collections.abc import Iterable +from ipaddress import AddressValueError, IPv4Address, IPv4Interface +from re import sub +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, +) + + +class ConfigViewInterfaceCiscoNXOS(ConfigViewInterfaceBase): # noqa: PLR0904 + @property + def bundle_id(self) -> Optional[str]: + raise NotImplementedError + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + raise NotImplementedError + + @property + def bundle_name(self) -> Optional[str]: + raise NotImplementedError + + @property + def description(self) -> str: + if child := self.config.get_child(startswith="description "): + return child.text.split(maxsplit=1)[1] + return "" + + @property + def duplex(self) -> InterfaceDuplex: + raise NotImplementedError + + @property + def enabled(self) -> bool: + raise NotImplementedError + + @property + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" + raise NotImplementedError + + @property + def ipv4_interface(self) -> Optional[IPv4Interface]: + return next(iter(self.ipv4_interfaces), None) + + @property + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + for ipv4_address_obj in self.config.get_children(startswith="ip address "): + ipv4_address = ipv4_address_obj.text.split() + try: + yield IPv4Interface("/".join(ipv4_address[2:4])) + except AddressValueError: + continue + + @property + def is_bundle(self) -> bool: + return self.name.lower().startswith(self._bundle_prefix) + + @property + def is_loopback(self) -> bool: + return self.name.lower().startswith("loopback") + + @property + def is_subinterface(self) -> bool: + return "." in self.name + + @property + def is_svi(self) -> bool: + return self.name.lower().startswith("vlan") + + @property + def module_number(self) -> Optional[int]: + words = self.number.split("/", 1) + if len(words) == 1: + return None + return int(words[0]) + + @property + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC control direction in configured.""" + raise NotImplementedError + + @property + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + raise NotImplementedError + + @property + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + raise NotImplementedError + + @property + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" + raise NotImplementedError + + @property + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + raise NotImplementedError + + @property + def name(self) -> str: + return self.config.text.split()[1] + + @property + def native_vlan(self) -> Optional[int]: + raise NotImplementedError + + @property + def number(self) -> str: + return sub("^[a-zA-Z-]+", "", self.name) + + @property + def parent_name(self) -> Optional[str]: + if self.is_subinterface: + return self.name.split(".")[0] + return None + + @property + def poe(self) -> bool: + raise NotImplementedError + + @property + def port_number(self) -> int: + return int(self.name.split("/")[-1].split(".")[0]) + + @property + def speed(self) -> Optional[tuple[int, ...]]: + raise NotImplementedError + + @property + def subinterface_number(self) -> Optional[int]: + return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None + + @property + def tagged_all(self) -> bool: + raise NotImplementedError + + @property + def tagged_vlans(self) -> tuple[int, ...]: + raise NotImplementedError + + @property + def vrf(self) -> str: + raise NotImplementedError + + @property + def _bundle_prefix(self) -> str: + return "port-channel" + + +class HConfigViewCiscoNXOS(HConfigViewBase): + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + raise NotImplementedError + + @property + def hostname(self) -> Optional[str]: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower() + return None + + @property + def interface_names_mentioned(self) -> frozenset[str]: + """Returns a set with all the interface names mentioned in the config.""" + raise NotImplementedError + + @property + def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoNXOS]: + for interface in self.interfaces: + yield ConfigViewInterfaceCiscoNXOS(interface) + + @property + def interfaces(self) -> Iterable[HConfigChild]: + return self.config.get_children(startswith="interface ") + + @property + def ipv4_default_gw(self) -> Optional[IPv4Address]: + raise NotImplementedError + + @property + def location(self) -> str: + raise NotImplementedError + + @property + def stack_members(self) -> Iterable[StackMember]: + raise NotImplementedError + + @property + def vlans(self) -> Iterable[Vlan]: + raise NotImplementedError diff --git a/hier_config/platforms/cisco_xr/__init__.py b/hier_config/platforms/cisco_xr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/cisco_xr/driver.py b/hier_config/platforms/cisco_xr/driver.py new file mode 100644 index 0000000..6df3deb --- /dev/null +++ b/hier_config/platforms/cisco_xr/driver.py @@ -0,0 +1,298 @@ +from collections.abc import Iterable +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.models import ( + IdempotentCommandsRule, + IndentAdjustRule, + MatchRule, + OrderingRule, + ParentAllowsDuplicateChildRule, + PerLineSubRule, + SectionalExitingRule, + SectionalOverwriteNoNegateRule, + SectionalOverwriteRule, +) +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverCiscoIOSXR(HConfigDriverBase): # pylint: disable=too-many-instance-attributes + def idempotent_for( + self, + config: HConfigChild, + other_children: Iterable[HConfigChild], + ) -> Optional[HConfigChild]: + if isinstance(config.parent, HConfigChild): + acl = ("ipv4 access-list ", "ipv6 access-list ") + if config.parent.text.startswith(acl): + self_sn = config.text.split(" ", 1)[0] + for other_child in other_children: + other_sn = other_child.text.split(" ", 1)[0] + if self_sn == other_sn: + return other_child + + return super().idempotent_for(config, other_children) + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + sectional_exiting=[ + SectionalExitingRule( + match_rules=(MatchRule(startswith="route-policy"),), + exit_text="end-policy", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="prefix-set"),), + exit_text="end-set", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="policy-map"),), + exit_text="end-policy-map", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="class-map"),), + exit_text="end-class-map", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="community-set"),), + exit_text="end-set", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="extcommunity-set"),), + exit_text="end-set", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="template"),), + exit_text="end-template", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="interface"),), + exit_text="root", + ), + SectionalExitingRule( + match_rules=(MatchRule(startswith="router bgp"),), + exit_text="root", + ), + ], + sectional_overwrite=[ + SectionalOverwriteRule(match_rules=(MatchRule(startswith="template"),)), + ], + sectional_overwrite_no_negate=[ + SectionalOverwriteNoNegateRule( + match_rules=(MatchRule(startswith="as-path-set"),) + ), + SectionalOverwriteNoNegateRule( + match_rules=(MatchRule(startswith="prefix-set"),) + ), + SectionalOverwriteNoNegateRule( + match_rules=(MatchRule(startswith="route-policy"),) + ), + SectionalOverwriteNoNegateRule( + match_rules=(MatchRule(startswith="extcommunity-set"),), + ), + SectionalOverwriteNoNegateRule( + match_rules=(MatchRule(startswith="community-set"),), + ), + ], + ordering=[ + OrderingRule( + match_rules=(MatchRule(startswith="vrf "),), + weight=-200, + ), + OrderingRule( + match_rules=(MatchRule(startswith="no vrf "),), + weight=200, + ), + ], + indent_adjust=[ + IndentAdjustRule( + start_expression="^\\s*template", + end_expression="^\\s*end-template", + ), + ], + parent_allows_duplicate_child=[ + ParentAllowsDuplicateChildRule( + match_rules=(MatchRule(startswith="route-policy"),) + ), + ], + per_line_sub=[ + PerLineSubRule(search="^Building configuration.*", replace=""), + PerLineSubRule(search="^Current configuration.*", replace=""), + PerLineSubRule(search="^ntp clock-period .*", replace=""), + PerLineSubRule(search=".*speed.*", replace=""), + PerLineSubRule(search=".*duplex.*", replace=""), + PerLineSubRule(search=".*negotiation auto.*", replace=""), + PerLineSubRule(search=".*parity none.*", replace=""), + PerLineSubRule(search="^end-policy$", replace=" end-policy"), + PerLineSubRule(search="^end-set$", replace=" end-set"), + PerLineSubRule(search="^end$", replace=""), + PerLineSubRule(search="^\\s*[#!].*", replace=""), + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="address-family"), + MatchRule(startswith="additional-paths selection route-policy"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="bgp router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="neighbor-group"), + MatchRule(startswith="address-family"), + MatchRule(startswith="soft-reconfiguration inbound"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="address-family"), + MatchRule(startswith="soft-reconfiguration inbound"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="address-family"), + MatchRule(startswith="maximum-prefix"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="password"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="vrf"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="description"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="description"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router bgp"), + MatchRule(startswith="neighbor"), + MatchRule(startswith="password"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="area"), + MatchRule(startswith="interface"), + MatchRule(startswith="cost"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="area"), + MatchRule(startswith="message-digest-key"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="router ospf"), + MatchRule(startswith="max-metric router-lsa"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="l2vpn"), + MatchRule(startswith="router-id"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(re_search="logging \\d+.\\d+.\\d+.\\d+ vrf MGMT"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="line default"), + MatchRule(startswith="access-class ingress"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="line default"), + MatchRule(startswith="transport input"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="hostname"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="logging source-interface"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="ipv4 address"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server community"),), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="snmp-server location"),), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="line console"), + MatchRule(startswith="exec-timeout"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="mpls ldp"), + MatchRule(startswith="session protection duration"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(equals="mpls ldp"), + MatchRule(startswith="igp sync delay"), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface"), + MatchRule(startswith="mtu"), + ), + ), + IdempotentCommandsRule( + match_rules=(MatchRule(startswith="banner"),), + ), + ], + ) diff --git a/hier_config/platforms/cisco_xr/view.py b/hier_config/platforms/cisco_xr/view.py new file mode 100644 index 0000000..c027ac9 --- /dev/null +++ b/hier_config/platforms/cisco_xr/view.py @@ -0,0 +1,209 @@ +from collections.abc import Iterable +from ipaddress import AddressValueError, IPv4Address, IPv4Interface +from re import sub +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, +) + + +class ConfigViewInterfaceCiscoIOSXR(ConfigViewInterfaceBase): # noqa: PLR0904 + @property + def _bundle_prefix(self) -> str: + return "Bundle-Ether" + + @property + def bundle_id(self) -> Optional[str]: + raise NotImplementedError + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + raise NotImplementedError + + @property + def bundle_name(self) -> Optional[str]: + if self.bundle_id: + return f"{self._bundle_prefix}{self.bundle_id}" + return None + + @property + def description(self) -> str: + if child := self.config.get_child(startswith="description "): + return child.text.split(maxsplit=1)[1] + return "" + + @property + def duplex(self) -> InterfaceDuplex: + raise NotImplementedError + + @property + def enabled(self) -> bool: + raise NotImplementedError + + @property + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" + raise NotImplementedError + + @property + def ipv4_interface(self) -> Optional[IPv4Interface]: + return next(iter(self.ipv4_interfaces), None) + + @property + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + for ipv4_address_obj in self.config.get_children(startswith="ipv4 address "): + ipv4_address = ipv4_address_obj.text.split() + try: + yield IPv4Interface("/".join(ipv4_address[2:4])) + except AddressValueError: + continue + + @property + def is_bundle(self) -> bool: + return self.name.lower().startswith(self._bundle_prefix) + + @property + def is_loopback(self) -> bool: + return self.name.lower().startswith("loopback") + + @property + def is_subinterface(self) -> bool: + return "." in self.name + + @property + def is_svi(self) -> bool: + return self.name.lower().startswith("vlan") + + @property + def module_number(self) -> Optional[int]: + words = self.number.split("/", 1) + if len(words) == 1: + return None + return int(words[0]) + + @property + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC control direction in configured.""" + raise NotImplementedError + + @property + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + raise NotImplementedError + + @property + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + raise NotImplementedError + + @property + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" + raise NotImplementedError + + @property + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + raise NotImplementedError + + @property + def name(self) -> str: + return self.config.text.split()[1] + + @property + def native_vlan(self) -> Optional[int]: + raise NotImplementedError + + @property + def number(self) -> str: + return sub("^[a-zA-Z-]+", "", self.name) + + @property + def parent_name(self) -> Optional[str]: + if self.is_subinterface: + return self.name.split(".")[0] + return None + + @property + def poe(self) -> bool: + raise NotImplementedError + + @property + def port_number(self) -> int: + return int(self.name.split("/")[-1].split(".")[0]) + + @property + def speed(self) -> Optional[tuple[int, ...]]: + raise NotImplementedError + + @property + def subinterface_number(self) -> Optional[int]: + return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None + + @property + def tagged_all(self) -> bool: + raise NotImplementedError + + @property + def tagged_vlans(self) -> tuple[int, ...]: + raise NotImplementedError + + @property + def vrf(self) -> str: + raise NotImplementedError + + +class HConfigViewCiscoIOSXR(HConfigViewBase): + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + raise NotImplementedError + + @property + def hostname(self) -> Optional[str]: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower() + return None + + @property + def interface_names_mentioned(self) -> frozenset[str]: + raise NotImplementedError + + @property + def interface_views(self) -> Iterable[ConfigViewInterfaceCiscoIOSXR]: + for interface in self.interfaces: + yield ConfigViewInterfaceCiscoIOSXR(interface) + + @property + def interfaces(self) -> Iterable[HConfigChild]: + return self.config.get_children(startswith="interface ") + + @property + def ipv4_default_gw(self) -> Optional[IPv4Address]: + raise NotImplementedError + + @property + def location(self) -> str: + raise NotImplementedError + + @property + def stack_members(self) -> Iterable[StackMember]: + raise NotImplementedError + + @property + def vlans(self) -> Iterable[Vlan]: + raise NotImplementedError diff --git a/hier_config/platforms/driver_base.py b/hier_config/platforms/driver_base.py new file mode 100644 index 0000000..e95e842 --- /dev/null +++ b/hier_config/platforms/driver_base.py @@ -0,0 +1,95 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from typing import Optional + +from pydantic import Field, PositiveInt + +from hier_config.child import HConfigChild +from hier_config.models import ( + BaseModel, + FullTextSubRule, + IdempotentCommandsAvoidRule, + IdempotentCommandsRule, + IndentAdjustRule, + NegationDefaultWhenRule, + NegationDefaultWithRule, + OrderingRule, + ParentAllowsDuplicateChildRule, + PerLineSubRule, + SectionalExitingRule, + SectionalOverwriteNoNegateRule, + SectionalOverwriteRule, +) +from hier_config.root import HConfig + + +class HConfigDriverRules(BaseModel): # pylint: disable=too-many-instance-attributes + full_text_sub: list[FullTextSubRule] = Field(default_factory=list) + idempotent_commands: list[IdempotentCommandsRule] = Field(default_factory=list) + idempotent_commands_avoid: list[IdempotentCommandsAvoidRule] = Field( + default_factory=list + ) + indent_adjust: list[IndentAdjustRule] = Field(default_factory=list) + indentation: PositiveInt = 2 + negation_default_when: list[NegationDefaultWhenRule] = Field(default_factory=list) + negate_with: list[NegationDefaultWithRule] = Field(default_factory=list) + ordering: list[OrderingRule] = Field(default_factory=list) + parent_allows_duplicate_child: list[ParentAllowsDuplicateChildRule] = Field( + default_factory=list + ) + per_line_sub: list[PerLineSubRule] = Field(default_factory=list) + post_load_callbacks: list[Callable[[HConfig], None]] = Field(default_factory=list) + sectional_exiting: list[SectionalExitingRule] = Field(default_factory=list) + sectional_overwrite: list[SectionalOverwriteRule] = Field(default_factory=list) + sectional_overwrite_no_negate: list[SectionalOverwriteNoNegateRule] = Field( + default_factory=list + ) + + +class HConfigDriverBase(ABC): + """Defines all hier_config options, rules, and rule checking methods. + Override methods as needed. + """ + + def __init__(self) -> None: + self.rules = self._instantiate_rules() + + def idempotent_for( + self, + config: HConfigChild, + other_children: Iterable[HConfigChild], + ) -> Optional[HConfigChild]: + for rule in self.rules.idempotent_commands: + if config.is_lineage_match(rule.match_rules): + for other_child in other_children: + if other_child.is_lineage_match(rule.match_rules): + return other_child + return None + + def negate_with(self, config: HConfigChild) -> Optional[str]: + for with_rule in self.rules.negate_with: + if config.is_lineage_match(with_rule.match_rules): + return with_rule.use + return None + + def swap_negation(self, child: HConfigChild) -> HConfigChild: + """Swap negation of a `child.text`.""" + if child.text.startswith(self.negation_prefix): + child.text = child.text_without_negation + else: + child.text = f"{self.negation_prefix}{child.text}" + + return child + + @property + def declaration_prefix(self) -> str: + return "" + + @property + def negation_prefix(self) -> str: + return "no " + + @staticmethod + @abstractmethod + def _instantiate_rules() -> HConfigDriverRules: + pass diff --git a/hier_config/platforms/functions.py b/hier_config/platforms/functions.py new file mode 100644 index 0000000..f0f90b1 --- /dev/null +++ b/hier_config/platforms/functions.py @@ -0,0 +1,15 @@ +def expand_range(number_range_str: str) -> tuple[int, ...]: + """Expand ranges like 2-5,8,22-45.""" + numbers: list[int] = [] + for number_range in number_range_str.split(","): + start_stop = number_range.split("-") + if len(start_stop) == 2: + start = int(start_stop[0]) + stop = int(start_stop[1]) + numbers.extend(n for n in range(start, stop + 1)) + else: + numbers.append(int(start_stop[0])) + if len(set(numbers)) != len(numbers): + message = "len(set(numbers)) must be equal to len(numbers)." + raise ValueError(message) + return tuple(numbers) diff --git a/hier_config/platforms/generic/__init__.py b/hier_config/platforms/generic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/generic/driver.py b/hier_config/platforms/generic/driver.py new file mode 100644 index 0000000..0de25e3 --- /dev/null +++ b/hier_config/platforms/generic/driver.py @@ -0,0 +1,7 @@ +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverGeneric(HConfigDriverBase): + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() diff --git a/hier_config/platforms/hp_comware5/__init__.py b/hier_config/platforms/hp_comware5/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/hp_comware5/driver.py b/hier_config/platforms/hp_comware5/driver.py new file mode 100644 index 0000000..a25355a --- /dev/null +++ b/hier_config/platforms/hp_comware5/driver.py @@ -0,0 +1,11 @@ +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverHPComware5(HConfigDriverBase): + @property + def negation_prefix(self) -> str: + return "undo " + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() diff --git a/hier_config/platforms/hp_procurve/__init__.py b/hier_config/platforms/hp_procurve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/hp_procurve/driver.py b/hier_config/platforms/hp_procurve/driver.py new file mode 100644 index 0000000..2db8182 --- /dev/null +++ b/hier_config/platforms/hp_procurve/driver.py @@ -0,0 +1,327 @@ +import re +from collections.abc import Iterable +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.models import ( + IdempotentCommandsRule, + MatchRule, + NegationDefaultWithRule, + OrderingRule, + PerLineSubRule, +) +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules +from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range +from hier_config.root import HConfig + + +def _fixup_hp_procurve_aaa_port_access_fixup(config: HConfig) -> None: + """Expands the interface ranges present in aaa port-access commands. + + aaa port-access authenticator 1/15-1/20,1/26-1/40,2/14-2/20,2/25-2/28,2/30-2/44,3/8-3/44,4/1-4/2,4/8-4/44,5/1-5/2,5/8-5/15,5/17-5/28,5/30-5/44 + aaa port-access mac-based 1/15-1/20,1/26-1/40,2/14-2/20,2/25-2/28,2/30-2/44,3/8-3/44,4/1-4/2,4/8-4/44,5/1-5/2,5/8-5/28,5/30-5/44. + + to + aaa port-access authenticator 1/15 + aaa port-access authenticator 1/16 + ... + """ + for aaa_port_access in tuple( + config.get_children( + re_search=r"^aaa port-access (authenticator|mac-based) [0-9,/\-Ttrk]+$", + ), + ): + words = aaa_port_access.text.split() + if not any(c in words[3] for c in ("-", ",")): + continue + for interface_name in hp_procurve_expand_range(words[3]): + config.add_child(f"aaa port-access {words[2]} {interface_name}") + aaa_port_access.delete() + + +def _fixup_hp_procurve_vlan(config: HConfig) -> None: + """Move native/tagged vlan config to the interface config for easier modeling and remediation. + + vlan 1 + no untagged 1/2-1/22,1/26-1/44,2/2-2/21,2/26-2/44 + vlan 80 + untagged 2/43-2/44,3/43-3/44,4/43-4/44,5/29,5/43-5/44 + tagged 1/23,2/23,Trk1. + + to + + interface 2/43 + untagged vlan 80 + interface 1/23 + tagged vlan 80 + tagged vlan 90 + untagged vlan 10 + ... + + Also, this effectively creates TrkX interfaces in the running config + """ + for vlan in tuple(config.get_children(startswith="vlan ")): + vlan_id = vlan.text.split()[1] + if untagged_interfaces := vlan.get_child(startswith="untagged "): + untagged_interface_names = hp_procurve_expand_range( + untagged_interfaces.text.split()[1], + ) + for untagged_interface_name in sorted(untagged_interface_names): + config.add_children_deep( + ( + f"interface {untagged_interface_name}", + f"untagged vlan {vlan_id}", + ), + ) + untagged_interfaces.delete() + + if tagged_interfaces := vlan.get_child(startswith="tagged "): + tagged_interface_names = hp_procurve_expand_range( + tagged_interfaces.text.split()[1], + ) + for tagged_interface_name in sorted(tagged_interface_names): + config.add_children_deep( + (f"interface {tagged_interface_name}", f"tagged vlan {vlan_id}"), + ) + tagged_interfaces.delete() + + if no_untagged_interfaces := vlan.get_child(startswith="no untagged "): + no_untagged_interfaces.delete() + + +def _fixup_hp_procurve_device_profile(config: HConfig) -> None: + """Separates the device-profile tagged-vlans onto individual lines. + + device-profile name "phone" + tagged-vlan 10,20 + + to + + device-profile name "phone" + tagged-vlan 10 + tagged-vlan 20 + """ + for device_profile in config.get_children(startswith="device-profile name "): + if tagged_vlan := device_profile.get_child(startswith="tagged-vlan "): + words = tagged_vlan.text.split() + if not any(c in words[1] for c in ("-", ",")): + continue + for vlan in sorted(hp_procurve_expand_range(words[1])): + device_profile.add_child(f"tagged-vlan {vlan}") + tagged_vlan.delete() + + +class HConfigDriverHPProcurve(HConfigDriverBase): + def idempotent_for( + self, + config: HConfigChild, + other_children: Iterable[HConfigChild], + ) -> Optional[HConfigChild]: + if result := super().idempotent_for(config, other_children): + return result + + if config.parent is config.root: + rules = ( + ( + r"^aaa port-access authenticator \S+ (tx-period|supplicant-timeout) \d+$", + 5, + ), + (r"^aaa port-access \S+ auth-(priority|order) ", 4), + (r"^aaa port-access authenticator \S+ client-limit \d+$", 5), + (r"^aaa port-access mac-based \S+ (addr-limit|logoff-period) \d+$", 5), + (r"^aaa port-access \S+ critical-auth user-role ", 5), + (r"^radius-server host \S+ encrypted-key \S+$", 4), + ) + for expression, stop_index in rules: + if result := self._idempotent_for_helper( + expression, + stop_index, + config, + other_children, + ): + return result + + return None + + @staticmethod + def _idempotent_for_helper( + expression: str, + end_index: int, + config: HConfigChild, + other_children: Iterable[HConfigChild], + ) -> Optional[HConfigChild]: + if re.search(expression, config.text): + words = config.text.split() + startswith = " ".join(words[:end_index]) + for other_child in other_children: + if other_child.text.startswith(startswith): + return other_child + return None + + def negate_with(self, config: HConfigChild) -> Optional[str]: + result = super().negate_with(config) + if isinstance(result, str): + return result + + if config.parent is not config.root: + return None + + rules = ( + ( + r"^aaa port-access authenticator \S+ (tx-period|supplicant-timeout) \d+$", + 5, + "", + "30", + ), + (r"^aaa port-access authenticator \S+ client-limit \d+$", 5, "no", ""), + (r"^aaa port-access mac-based \S+ addr-limit \d+$", 5, "", "1"), + (r"^aaa port-access mac-based \S+ logoff-period \d+$", 5, "", "300"), + (r"^aaa port-access \S+ critical-auth user-role ", 5, "no", ""), + (r"^tacacs-server host \S+ ", 3, "no", ""), + (r"^radius-server host \S+ time-window \d+$", 4, "", "300"), + ( + r"^radius-server host \S+ time-window plus-or-minus-time-window$", + 4, + "", + "positive-time-window", + ), + (r"^radius-server host \S+ encrypted-key \S+$", 3, "no", ""), + ) + for expression, end_index, prepend, append in rules: + if result := self._negation_negate_with_helper( + expression, + end_index, + prepend, + append, + config, + ): + return result + return None + + @staticmethod + def _negation_negate_with_helper( + expression: str, + end_index: int, + prepend: str, + append: str, + config: HConfigChild, + ) -> Optional[str]: + if re.search(expression, config.text): + words = config.text.split() + return " ".join([prepend] + words[:end_index] + [append]).strip() + return None + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules( + negate_with=[ + NegationDefaultWithRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(equals="disable"), + ), + use="enable", + ), + NegationDefaultWithRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="name "), + ), + use="no name", + ), + ], + per_line_sub=[ + PerLineSubRule(search=r"^\s*[#!].*", replace=""), + PerLineSubRule(search=r"^; .*", replace=""), + PerLineSubRule(search=r"^Running configuration:*", replace=""), + ], + idempotent_commands=[ + IdempotentCommandsRule( + match_rules=( + MatchRule( + startswith="aaa authentication port-access eap-radius" + ), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="aaa accounting update periodic "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="untagged vlan "), + ), + ), + IdempotentCommandsRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith="name "), + ), + ), + ], + ordering=[ + # no aaa port-access {{ interface_name }} auth-priority -- needs to happen before auth-order + OrderingRule( + match_rules=( + MatchRule(re_search=r"^no aaa port-access \S+ auth-priority"), + ), + weight=-10, + ), + # `no aaa port-access authenticator 5/43` needs to come before other similar commands + # e.g. `no aaa port-access authenticator 5/43 client-limit` + OrderingRule( + match_rules=( + MatchRule(re_search=r"^no aaa port-access authenticator \S+$"), + ), + weight=-10, + ), + # `aaa server-group radius "ise" host 172.16.1.1` should be defined after reference + OrderingRule( + match_rules=( + MatchRule(re_search=r"^aaa server-group radius \S+ host "), + ), + weight=10, + ), + # Need to add vlans before removing to prevent accidentally adding untagged vlan 1 + OrderingRule( + match_rules=( + MatchRule(startswith="interface "), + MatchRule(startswith=("no tagged vlan ", "no untagged vlan ")), + ), + weight=10, + ), + OrderingRule( + match_rules=(MatchRule(startswith="no tacacs-server "),), + weight=10, + ), + # In case a server is a member of a group, `no radius-server host 172.16.1.1 dyn-authorization` cannot + # be used before adding another server to that group + OrderingRule( + match_rules=( + MatchRule( + re_search=r"^no radius-server host \S+ dyn-authorization$" + ), + ), + weight=15, + ), + # Cannot use `no aaa server-group radius "ise" host 172.16.1.1` after removing that host + OrderingRule( + match_rules=( + MatchRule(re_search=r"^no aaa server-group radius \S+ host "), + ), + weight=20, + ), + # `no radius-server host 172.16.1.1` should be called last (cannot leave a server group empty) + OrderingRule( + match_rules=(MatchRule(re_search=r"^no radius-server host \S+$"),), + weight=30, + ), + ], + post_load_callbacks=[ + _fixup_hp_procurve_aaa_port_access_fixup, + _fixup_hp_procurve_device_profile, + _fixup_hp_procurve_vlan, + ], + ) diff --git a/hier_config/platforms/hp_procurve/functions.py b/hier_config/platforms/hp_procurve/functions.py new file mode 100644 index 0000000..d3ffe25 --- /dev/null +++ b/hier_config/platforms/hp_procurve/functions.py @@ -0,0 +1,51 @@ +def hp_procurve_expand_range(interface_range_str: str) -> tuple[str, ...]: + """Expand interface ranges like 1/2-5,2/22-45.""" + interfaces: list[str] = [] + for interface_range in interface_range_str.split(","): + _hp_procurve_expand_range_segment(interface_range, interfaces) + if len(frozenset(interfaces)) != len(interfaces): + message = ( + "the length of frozenset(interfaces) was not the same as len(interfaces)" + ) + raise ValueError(message) + return tuple(interfaces) + + +def _hp_procurve_expand_range_segment( + interface_range: str, + interfaces: list[str], +) -> None: + start_stop = interface_range.split("-") + if len(start_stop) != 2: + interfaces.append(start_stop[0]) + return + + start_port_prefix = "" + trk = "Trk" + if start_stop[0].startswith(trk): + stack_member = trk + start_port_number = start_stop[0].removeprefix(trk) + end_port_number = start_stop[1].removeprefix(trk) + elif "/" in start_stop[0]: + stack_member, start_port_number = start_stop[0].split("/") + stack_member += "/" + end_port_number = start_stop[1].split("/")[-1] + # account for `interface 5/A1` + for letter in ("A", "B", "C", "D"): + if start_port_number.startswith(letter): + start_port_prefix = letter + start_port_number = start_port_number.removeprefix(letter) + if not end_port_number.startswith(letter): + message = f"{letter=}, the end_port_number should start with the same letter" + raise ValueError(message) + end_port_number = end_port_number.removeprefix(letter) + break + else: + stack_member = "" + start_port_number = start_stop[0] + end_port_number = start_stop[1] + + interfaces.extend( + f"{stack_member}{start_port_prefix}{port}" + for port in range(int(start_port_number), int(end_port_number) + 1) + ) diff --git a/hier_config/platforms/hp_procurve/view.py b/hier_config/platforms/hp_procurve/view.py new file mode 100644 index 0000000..48aad63 --- /dev/null +++ b/hier_config/platforms/hp_procurve/view.py @@ -0,0 +1,350 @@ +import re +from collections.abc import Iterable +from ipaddress import AddressValueError, IPv4Address, IPv4Interface +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.functions import expand_range +from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.platforms.view_base import ( + ConfigViewInterfaceBase, + HConfigViewBase, +) + + +class ConfigViewInterfaceHPProcurve( # noqa: PLR0904 pylint: disable=abstract-method + ConfigViewInterfaceBase, +): + @property + def bundle_id(self) -> Optional[str]: + raise NotImplementedError + + @property + def bundle_member_interfaces(self) -> Iterable[str]: + # trunk 1/45,2/45 trk1 trunk + bundle = self.config.parent.get_child( + re_search=rf"^trunk .* {self.name.lower()} (trunk|lacp)$", + ) + if self.is_bundle and bundle is None: + message = ( + f"Interface is a bundle but bundle config was not found: {self.name}" + ) + raise TypeError(message) + if bundle is None: + message = f"The bundle config line couldn't be found: {self.name}" + raise ValueError(message) + return hp_procurve_expand_range(bundle.text.split()[1]) + + @property + def bundle_name(self) -> Optional[str]: + for bundle_def in self.config.parent.get_children(startswith="trunk "): + interface_range = bundle_def.text.split()[1] + interfaces = hp_procurve_expand_range(interface_range) + if self.name in interfaces: + # Capitalizing the interface name is consistent with 1/A1 interface naming + # and is consistent with references under `vlan 10/n tagged Trk1` + return bundle_def.text.split()[2].capitalize() + return None + + @property + def description(self) -> str: + if child := self.config.get_child(startswith="name "): + return child.text.split(maxsplit=1)[1].replace('"', "") + return "" + + @property + def duplex(self) -> InterfaceDuplex: + if duplex := self.config.get_child(startswith="speed-duplex "): + return _duplex_from_speed_duplex(duplex.text) + return InterfaceDuplex.AUTO + + @property + def enabled(self) -> bool: + return not self.config.get_child(equals="disable") + + @property + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" + return any( + line in self.config.parent.children_dict + for line in ( + f"aaa port-access authenticator {self.name}", + f"aaa port-access mac-based {self.name}", + ) + ) + + @property + def ipv4_interface(self) -> Optional[IPv4Interface]: + return next(iter(self.ipv4_interfaces), None) + + @property + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + for ipv4_address_obj in self.config.get_children(startswith="ip address "): + ipv4_address = ipv4_address_obj.text.split() + try: + yield IPv4Interface("/".join(ipv4_address[2:4])) + except AddressValueError: + continue + + @property + def is_bundle(self) -> bool: + return self.name.lower().startswith(self._bundle_prefix) + + @property + def is_loopback(self) -> bool: + return self.name.lower().startswith("loopback") + + @property + def is_subinterface(self) -> bool: + return "." in self.name + + @property + def is_svi(self) -> bool: + return self.name.lower().startswith("vlan") + + @property + def module_number(self) -> Optional[int]: + words = self.number.split("/", 1) + if len(words) == 1: + return None + return int(words[0]) + + @property + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC control direction in configured.""" + return ( + f"aaa port-access {self.name} controlled-direction in" + in self.config.parent.children_dict + ) + + @property + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + # hp_procurve does not support host mode + return None + + @property + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + return bool( + self.config.parent.get_child( + equals=f"aaa port-access {self.name} auth-order mac-based authenticator", + ), + ) + + @property + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" + if child := self.config.parent.get_child( + startswith=f"aaa port-access authenticator {self.name} client-limit ", + ): + return int(child.text.split()[5]) + return 1 + + @property + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + if child := self.config.parent.get_child( + startswith=f"aaa port-access mac-based {self.name} addr-limit ", + ): + return int(child.text.split()[5]) + return 1 + + @property + def name(self) -> str: + if self.config.text.startswith("interface "): + return self.config.text.split()[1] + return self.config.text + + @property + def native_vlan(self) -> Optional[int]: + if vlan := self.config.get_child(startswith="untagged vlan "): + return int(vlan.text.split()[2]) + return None + + @property + def number(self) -> str: + return re.sub("^[a-zA-Z-]+", "", self.name) + + @property + def parent_name(self) -> Optional[str]: + if self.is_subinterface: + return self.name.split(".")[0] + return None + + @property + def poe(self) -> bool: + return not self.config.get_child(equals="no power-over-ethernet") + + @property + def port_number(self) -> int: + return int(self.name.split("/")[-1].split(".")[0]) + + @property + def speed(self) -> Optional[tuple[int, ...]]: + if speed := self.config.get_child(startswith="speed-duplex "): + return _speed_from_speed_duplex(speed.text) + return None + + @property + def subinterface_number(self) -> Optional[int]: + return int(self.name.split(".")[0 - 1]) if self.is_subinterface else None + + @property + def tagged_all(self) -> bool: + return False + + @property + def tagged_vlans(self) -> tuple[int, ...]: + return tuple( + int(c.text.split()[2]) + for c in self.config.get_children(startswith="tagged vlan ") + ) + + @property + def vrf(self) -> str: + return "" + + @property + def _bundle_prefix(self) -> str: + return "trk" + + +def _speed_from_speed_duplex(speed_duplex: str) -> Optional[tuple[int, ...]]: + if speed_duplex.startswith("10"): + return (int(speed_duplex.split("-")[0]),) + if speed_duplex.startswith("auto-"): + return tuple(int(s) for s in speed_duplex.replace("auto-", "").split("-")) + return None + + +def _duplex_from_speed_duplex(speed_duplex: str) -> InterfaceDuplex: + if speed_duplex.startswith("auto"): + return InterfaceDuplex(speed_duplex[:4]) + if speed_duplex.endswith(("half", "full")): + return InterfaceDuplex(speed_duplex[-4:]) + return InterfaceDuplex(speed_duplex) + + +class HConfigViewHPProcurve(HConfigViewBase): + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + raise NotImplementedError + + @property + def hostname(self) -> Optional[str]: + if child := self.config.get_child(startswith="hostname "): + return child.text.split()[1].lower().replace('"', "") + return None + + @property + def interface_names_mentioned(self) -> frozenset[str]: + interfaces = {model.name for model in self.interface_views} + + for child in self.config.children: + text = child.text_without_negation + words = text.split() + if text.startswith("aaa port-access "): + found = ( + words[3] if words[2] in {"authenticator", "mac-based"} else words[2] + ) + + if re.search(r"^(\d+|\d+/\d+)$", found): + interfaces.add(found) + + return frozenset(interfaces) + + @property + def interface_views(self) -> Iterable[ConfigViewInterfaceHPProcurve]: + for interface in self.interfaces: + yield ConfigViewInterfaceHPProcurve(interface) + for vlan in self.config.get_children(startswith="vlan "): + if vlan.get_child(startswith="ip address "): + yield ConfigViewInterfaceHPProcurve(vlan) + + @property + def interfaces(self) -> Iterable[HConfigChild]: + return self.config.get_children(startswith="interface ") + + @property + def ipv4_default_gw(self) -> Optional[IPv4Address]: + if gateway := self.config.get_child(startswith="ip default-gateway "): + return IPv4Address(gateway.text.split()[2]) + return None + + @property + def location(self) -> str: + if location := self.config.get_child(startswith="snmp-server location "): + return location.text.split(maxsplit=2)[2].replace('"', "") + return "" + + @property + def stack_members(self) -> Iterable[StackMember]: + """stacking + member 1 type "JL123" mac-address abc123-abc123 + member 1 priority 255 + member 2 type "JL123" mac-address abc123-abc123 + member 2 priority 254 + ... + """ + stacking = self.config.get_child(equals="stacking") + if not stacking: + return + + for member in stacking.get_children(startswith="member"): + words = member.text.split() + if words[2] == "type": + member_id = int(words[1]) + yield StackMember( + id=member_id, + priority=256 - member_id, + mac_address=words[5], + model=words[3].replace('"', ""), + ) + + @property + def vlans(self) -> Iterable[Vlan]: + yielded_vlans: set[int] = set() + + # Yield explicitly defined VLANs + for child in self.config.get_children(re_search="^vlan [0-9,-]+$"): + vlan_name = None + if name := child.get_child(startswith="name "): + _, vlan_name = name.text.split(maxsplit=1) + vlan_name = vlan_name.replace('"', "") + for vlan_id in expand_range(child.text.split()[1]): + yielded_vlans.add(vlan_id) + yield Vlan( + id=vlan_id, + name=vlan_name or None, + ) + + # Yield any remaining unnamed VLANs mentioned on interfaces + for interface_view in self.interface_views: + for vlan_id in interface_view.tagged_vlans: + if vlan_id not in yielded_vlans: + yielded_vlans.add(vlan_id) + yield Vlan( + id=vlan_id, + name=None, + ) + if ( + native_vlan := interface_view.native_vlan + ) and native_vlan not in yielded_vlans: + yielded_vlans.add(native_vlan) + yield Vlan( + id=native_vlan, + name=None, + ) diff --git a/hier_config/platforms/juniper_junos/__init__.py b/hier_config/platforms/juniper_junos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/juniper_junos/driver.py b/hier_config/platforms/juniper_junos/driver.py new file mode 100644 index 0000000..c97a59e --- /dev/null +++ b/hier_config/platforms/juniper_junos/driver.py @@ -0,0 +1,28 @@ +from hier_config.child import HConfigChild +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverJuniperJUNOS(HConfigDriverBase): # pylint: disable=too-many-instance-attributes + def swap_negation(self, child: HConfigChild) -> HConfigChild: + """Swap negation of a `self.text`.""" + if child.text.startswith(self.negation_prefix): + child.text = f"{self.declaration_prefix}{child.text_without_negation}" + elif child.text.startswith(self.declaration_prefix): + child.text = f"{self.negation_prefix}{child.text.removeprefix(self.declaration_prefix)}" + else: + message = f"{child.text=} did not start with {self.negation_prefix} or {self.declaration_prefix}." + raise ValueError(message) + + return child + + @property + def declaration_prefix(self) -> str: + return "set " + + @property + def negation_prefix(self) -> str: + return "delete " + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() diff --git a/hier_config/platforms/models.py b/hier_config/platforms/models.py new file mode 100644 index 0000000..1e1ae20 --- /dev/null +++ b/hier_config/platforms/models.py @@ -0,0 +1,38 @@ +from enum import Enum, auto +from typing import Optional + +from pydantic import NonNegativeInt, PositiveInt + +from hier_config.models import BaseModel + + +class NACHostMode(str, Enum): + # Ordered from the most to the least secure + SINGLE_HOST = "single-host" + MULTI_DOMAIN = "multi-domain" + MULTI_AUTH = "multi-auth" + MULTI_HOST = "multi-host" + + +class InterfaceDot1qMode(str, Enum): + ACCESS = auto() + TAGGED = auto() + TAGGED_ALL = auto() + + +class StackMember(BaseModel): + id: NonNegativeInt + priority: NonNegativeInt + mac_address: Optional[str] # not defined in cisco_ios stacks + model: str + + +class InterfaceDuplex(str, Enum): + AUTO = auto() + FULL = auto() + HALF = auto() + + +class Vlan(BaseModel): + id: PositiveInt + name: Optional[str] diff --git a/hier_config/platforms/view_base.py b/hier_config/platforms/view_base.py new file mode 100644 index 0000000..be53b20 --- /dev/null +++ b/hier_config/platforms/view_base.py @@ -0,0 +1,272 @@ +from abc import ABC, abstractmethod +from collections.abc import Iterable +from ipaddress import IPv4Address, IPv4Interface +from typing import Optional + +from hier_config.child import HConfigChild +from hier_config.platforms.models import ( + InterfaceDot1qMode, + InterfaceDuplex, + NACHostMode, + StackMember, + Vlan, +) +from hier_config.root import HConfig + + +class ConfigViewInterfaceBase: # noqa: PLR0904 + def __init__(self, config: HConfigChild) -> None: + self.config = config + + @property + @abstractmethod + def bundle_id(self) -> Optional[str]: + """Determine the bundle ID.""" + + @property + @abstractmethod + def bundle_member_interfaces(self) -> Iterable[str]: + """Determine the member interfaces of a bundle.""" + + @property + @abstractmethod + def bundle_name(self) -> Optional[str]: + """Determine the bundle name of a bundle member.""" + + @property + @abstractmethod + def description(self) -> str: + """Determine the interface's description.""" + + @property + def dot1q_mode(self) -> Optional[InterfaceDot1qMode]: + """Derive the configured 802.1Q mode.""" + if self.tagged_all: + return InterfaceDot1qMode.TAGGED_ALL + if self.tagged_vlans: + return InterfaceDot1qMode.TAGGED + if self.native_vlan and not self.is_svi: + return InterfaceDot1qMode.ACCESS + return None + + @property + @abstractmethod + def duplex(self) -> InterfaceDuplex: + """Determine the configured Duplex of the interface.""" + + @property + @abstractmethod + def enabled(self) -> bool: + """Determines if the interface is enabled.""" + + @property + @abstractmethod + def has_nac(self) -> bool: + """Determine if the interface has NAC configured.""" + + @property + def ipv4_interface(self) -> Optional[IPv4Interface]: + """Determine the first configured IPv4Interface, address/prefix, object.""" + return next(iter(self.ipv4_interfaces), None) + + @property + @abstractmethod + def ipv4_interfaces(self) -> Iterable[IPv4Interface]: + """Determine the configured IPv4Interface, address/prefix, objects.""" + + @property + @abstractmethod + def is_bundle(self) -> bool: + """Determine if the interface is a bundle.""" + + @property + @abstractmethod + def is_loopback(self) -> bool: + """Determine if the interface is a loopback.""" + + @property + def is_subinterface(self) -> bool: + """Determine if the interface is a subinterface.""" + return "." in self.name + + @property + @abstractmethod + def is_svi(self) -> bool: + """Determine if the interface is an SVI.""" + + @property + @abstractmethod + def module_number(self) -> Optional[int]: + """Determine the module number of the interface.""" + + @property + @abstractmethod + def nac_control_direction_in(self) -> bool: + """Determine if the interface has NAC 'control direction in' configured.""" + + @property + @abstractmethod + def nac_host_mode(self) -> Optional[NACHostMode]: + """Determine the NAC host mode.""" + + @property + @abstractmethod + def nac_mab_first(self) -> bool: + """Determine if the interface has NAC configured for MAB first.""" + + @property + @abstractmethod + def nac_max_dot1x_clients(self) -> int: + """Determine the max dot1x clients.""" + + @property + @abstractmethod + def nac_max_mab_clients(self) -> int: + """Determine the max mab clients.""" + + @property + @abstractmethod + def name(self) -> str: + """Determine the name of the interface.""" + + @property + @abstractmethod + def native_vlan(self) -> Optional[int]: + """Determine the native VLAN.""" + + @property + @abstractmethod + def number(self) -> str: + """Remove letters from the interface name, leaving just numbers and symbols.""" + + @property + @abstractmethod + def parent_name(self) -> Optional[str]: + """Determine the parent bundle interface name.""" + + @property + @abstractmethod + def poe(self) -> bool: + """Determine if PoE is enabled.""" + + @property + @abstractmethod + def port_number(self) -> int: + """Determine the interface port number.""" + + @property + @abstractmethod + def speed(self) -> Optional[tuple[int, ...]]: + """Determine the statically allowed speeds the interface can operate at. In Mbps.""" + + @property + @abstractmethod + def subinterface_number(self) -> Optional[int]: + """Determine the sub-interface number.""" + + @property + @abstractmethod + def tagged_all(self) -> bool: + """Determine if all the VLANs are tagged.""" + + @property + @abstractmethod + def tagged_vlans(self) -> tuple[int, ...]: + """Determine the tagged VLANs.""" + + @property + @abstractmethod + def vrf(self) -> str: + """Determine the VRF.""" + + @property + @abstractmethod + def _bundle_prefix(self) -> str: + pass + + +class HConfigViewBase(ABC): + def __init__(self, config: HConfig) -> None: + self.config = config + + @property + def bundle_interface_views(self) -> Iterable[ConfigViewInterfaceBase]: + for interface_view in self.interface_views: + if interface_view.is_bundle: + yield interface_view + + @abstractmethod + def dot1q_mode_from_vlans( + self, + untagged_vlan: Optional[int] = None, + tagged_vlans: tuple[int, ...] = (), + *, + tagged_all: bool = False, + ) -> Optional[InterfaceDot1qMode]: + pass + + @property + @abstractmethod + def hostname(self) -> Optional[str]: + pass + + @property + @abstractmethod + def interface_names_mentioned(self) -> frozenset[str]: + """Returns a set with all the interface names mentioned in the config.""" + + def interface_view_by_name(self, name: str) -> Optional[ConfigViewInterfaceBase]: + for interface_view in self.interface_views: + if interface_view.name == name: + return interface_view + return None + + @property + @abstractmethod + def interface_views(self) -> Iterable[ConfigViewInterfaceBase]: + pass + + @property + @abstractmethod + def interfaces(self) -> Iterable[HConfigChild]: + pass + + @property + def interfaces_names(self) -> Iterable[str]: + for interface_view in self.interface_views: + yield interface_view.name + + @property + @abstractmethod + def ipv4_default_gw(self) -> Optional[IPv4Address]: + pass + + @property + @abstractmethod + def location(self) -> str: + pass + + @property + def module_numbers(self) -> Iterable[int]: + seen: set[int] = set() + for interface_view in self.interface_views: + if module_number := interface_view.module_number: + if module_number in seen: + continue + seen.add(module_number) + yield module_number + + @property + @abstractmethod + def stack_members(self) -> Iterable[StackMember]: + """Determine the configured stack members.""" + + @property + def vlan_ids(self) -> frozenset[int]: + """Determine the VLAN IDs.""" + return frozenset(vlan.id for vlan in self.vlans) + + @property + @abstractmethod + def vlans(self) -> Iterable[Vlan]: + """Determine the configured VLANs.""" diff --git a/hier_config/platforms/vyos/__init__.py b/hier_config/platforms/vyos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hier_config/platforms/vyos/driver.py b/hier_config/platforms/vyos/driver.py new file mode 100644 index 0000000..2a9aabd --- /dev/null +++ b/hier_config/platforms/vyos/driver.py @@ -0,0 +1,25 @@ +from hier_config.child import HConfigChild +from hier_config.platforms.driver_base import HConfigDriverBase, HConfigDriverRules + + +class HConfigDriverVYOS(HConfigDriverBase): # pylint: disable=too-many-instance-attributes + def swap_negation(self, child: HConfigChild) -> HConfigChild: + """Swap negation of a `self.text`.""" + if child.text.startswith(self.negation_prefix): + child.text = f"{self.declaration_prefix}{child.text_without_negation}" + elif child.text.startswith(self.declaration_prefix): + child.text = f"{self.negation_prefix}{child.text.removeprefix(self.declaration_prefix)}" + + return child + + @property + def declaration_prefix(self) -> str: + return "set " + + @property + def negation_prefix(self) -> str: + return "delete " + + @staticmethod + def _instantiate_rules() -> HConfigDriverRules: + return HConfigDriverRules() diff --git a/hier_config/root.py b/hier_config/root.py index 043b4cc..eb151e4 100644 --- a/hier_config/root.py +++ b/hier_config/root.py @@ -1,231 +1,156 @@ from __future__ import annotations -from itertools import islice -import re -from pathlib import Path -from typing import Optional, Set, Union, Iterator, List, TYPE_CHECKING, Tuple, Type + from logging import getLogger +from typing import TYPE_CHECKING, Optional, Union from .base import HConfigBase from .child import HConfigChild +from .models import Dump, DumpLine if TYPE_CHECKING: - from .host import Host + from collections.abc import Iterable, Iterator + + from hier_config.platforms.driver_base import HConfigDriverBase logger = getLogger(__name__) +# Refactoring ideas: +# - What if children were moved into its own class? e.g. child.children.add() +# - Cases of children.index() could be replaced with an identity based approach. -class HConfig(HConfigBase): # pylint: disable=too-many-public-methods - """ - A class for representing and comparing Cisco configurations in a +class HConfig(HConfigBase): # noqa: PLR0904 + """A class for representing and comparing Cisco like configurations in a hierarchical tree data structure. + """ - Example usage: - - .. code:: python - - # Setup basic environment - - from hier_config import HConfig, Host - import yaml - - options = yaml.safe_load(open('./tests/fixtures/options_ios.yml')) - host = Host('example.rtr', 'ios', options) - - # Build HConfig object for the Running Config - - running_config_hier = HConfig(host=host) - running_config_hier.load_from_file('./tests/fixtures/running_config.conf') - - # Build Hierarchical Configuration object for the Generated Config - - generated_config_hier = HConfig(host=host) - generated_config_hier.load_from_file('./tests/fixtures/generated_config.conf') - - # Build Hierarchical Configuration object for the Remediation Config + __slots__ = ("_driver",) - remediation_config_hier = running_config_hier.config_to_get_to(generated_config_hier) + def __init__(self, driver: HConfigDriverBase) -> None: + super().__init__() + self._driver = driver - for line in remediation_config_hier.all_children(): - print(line.cisco_style_text()) + def __str__(self) -> str: + return "\n".join(str(c) for c in sorted(self.children)) - See: + def __repr__(self) -> str: + return f"HConfig(driver={self.driver.__class__.__name__}, lines={self.dump_simple()})" - ./tests/fixtures/tags_ios.yml and ./tests/fixtures/options_ios.yml + def __hash__(self) -> int: + return hash(*self.children) - for test examples of options and tags. - """ + def __eq__(self, other: object) -> bool: + if not isinstance(other, HConfig): + return NotImplemented - def __init__(self, host: Host): - super().__init__() - assert hasattr(host, "hostname") - assert hasattr(host, "os") - assert hasattr(host, "hconfig_options") - self.host = host - self.parent = self - self.real_indent_level = -1 + if len(self.children) != len(other.children): + return False - self.options.setdefault("negation", "no") - self.options.setdefault("syntax_style", "cisco") - self._logs: List[str] = [] + return all( + self_child == other_child + for self_child, other_child in zip( + sorted(self.children), + sorted(other.children), + ) + ) - def __repr__(self) -> str: - return f"HConfig(host={self.host})" + @property + def driver(self) -> HConfigDriverBase: + return self._driver - def __hash__(self) -> int: - return id(self) + @property + def real_indent_level(self) -> int: + return -1 @property - def root(self) -> HConfig: - """returns the HConfig object at the base of the tree""" + def parent(self) -> HConfig: return self @property - def options(self) -> dict: - return self.host.hconfig_options + def root(self) -> HConfig: + """Returns the HConfig object at the base of the tree.""" + return self @property def is_leaf(self) -> bool: - """returns True if there are no children and is not an instance of HConfig""" + """Returns True if there are no children and is not an instance of HConfig.""" return False - @property - def logs(self) -> List[str]: - return self._logs - @property def is_branch(self) -> bool: - """returns True if there are children or is an instance of HConfig""" + """Returns True if there are children or is an instance of HConfig.""" return True - @property - def _child_class(self) -> Type[HConfigChild]: - return HConfigChild + def _instantiate_child(self, text: str) -> HConfigChild: + return HConfigChild(self, text) @property - def tags(self) -> Set[Optional[str]]: - """Recursive access to tags on all leaf nodes""" - found_tags: Set[Optional[str]] = set() + def tags(self) -> frozenset[str]: + """Recursive access to tags on all leaf nodes.""" + found_tags: set[str] = set() for child in self.children: found_tags.update(child.tags) - return found_tags + return frozenset(found_tags) @tags.setter - def tags(self, value: Set[str]) -> None: - """Recursive access to tags on all leaf nodes""" + def tags(self, value: frozenset[str]) -> None: + """Recursive access to tags on all leaf nodes.""" for child in self.children: - child.tags = value # type: ignore + child.tags = value - def merge(self, other: HConfig) -> None: - """Merges two HConfig objects""" - for child in other.children: - self.add_deep_copy_of(child, merged=True) + def merge(self, other: Union[HConfig, Iterable[HConfig]]) -> HConfig: + """Merges other HConfig objects into this one.""" + other_configs = (other,) if isinstance(other, HConfig) else other - def lineage(self) -> Iterator[HConfigChild]: - """ - Yields the lineage of parent objects, up to but excluding the root - """ + for other_config in other_configs: + for child in other_config.children: + self.add_deep_copy_of(child, merged=True) + + return self + + def add_children_deep(self, lines: Iterable[str]) -> HConfigChild: + """Add child instances of HConfigChild deeply.""" + base: Union[HConfig, HConfigChild] = self + for line in lines: + base = base.add_child(line) + if isinstance(base, HConfig): + message = "base was an HConfig object for some reason." + raise TypeError(message) + return base + + def lineage(self) -> Iterator[HConfigChild]: # noqa: PLR6301 + """Yields the lineage of parent objects, up to but excluding the root.""" yield from () - def load_from_file(self, file_path: Union[str, Path]) -> None: - """Load configuration text from a file""" - with open(file_path) as file: # pylint: disable=unspecified-encoding - config_text = file.read() - self.load_from_string(config_text) - - def load_from_string(self, config_text: str) -> None: - """Create Hierarchical Configuration nested objects from text""" - if self.options["syntax_style"] == "juniper": - config_text = self._convert_to_set_commands(config_text) - - for sub in self.options["full_text_sub"]: - config_text = re.sub(sub["search"], sub["replace"], config_text) - - self._load_from_string_lines(config_text) - - if self.host.os == "ios": - self._remove_acl_remarks() - self._add_acl_sequence_numbers() - self._rm_ipv6_acl_sequence_numbers() - - def load_from_dump(self, dump: List[dict]) -> None: - """Load an HConfig dump""" - last_item: Union[HConfig, HConfigChild] = self - for item in dump: - # parent is the root - if item["depth"] == 1: - parent: Union[HConfig, HConfigChild] = self - # has the same parent - elif last_item.depth() == item["depth"]: - parent = last_item.parent - # is a child object - elif last_item.depth() + 1 == item["depth"]: - parent = last_item - # has a parent somewhere closer to the root but not the root - else: - # last_item.lineage() = (a, b, c, d, e), new_item['depth'] = 2, - # parent = a - parent = next( - islice(last_item.lineage(), item["depth"] - 2, item["depth"] - 1) + def lines(self, *, sectional_exiting: bool = False) -> Iterable[str]: + for child in sorted(self.children): + yield from child.lines(sectional_exiting=sectional_exiting) + + def dump_simple(self, *, sectional_exiting: bool = False) -> tuple[str, ...]: + return tuple(self.lines(sectional_exiting=sectional_exiting)) + + def dump(self) -> Dump: + """Dump loaded HConfig data.""" + return Dump( + lines=tuple( + DumpLine( + depth=c.depth(), + text=c.text, + tags=frozenset(c.tags), + comments=frozenset(c.comments), + new_in_config=c.new_in_config, ) - # also accept 'line' - # obj = parent.add_child(item.get('text', item['line']), force_duplicate=True) - obj = parent.add_child(item["text"], force_duplicate=True) - obj.tags = set(item["tags"]) - obj.comments = set(item["comments"]) - obj.new_in_config = item["new_in_config"] - last_item = obj - - def dump(self, lineage_rules: Optional[List[dict]] = None) -> List[dict]: - """Dump a list of loaded HConfig data""" - if lineage_rules: - children = self.all_children_sorted_with_lineage_rules(lineage_rules) - else: - children = self.all_children_sorted() - - output = [] - for child in children: - output.append( - { - "depth": child.depth(), - "text": child.text, - "tags": list(child.tags), - "comments": list(child.comments), - "new_in_config": child.new_in_config, - } - ) + for c in self.all_children_sorted() + ), + ) - return output - - def add_tags(self, tag_rules: list, strip_negation: bool = False) -> None: - """ - Handler for tagging sections of Hierarchical Configuration data structure - for inclusion and exclusion. - """ - for rule in tag_rules: - for child in self.all_children(): - if child.lineage_test(rule, strip_negation): - if "add_tags" in rule: - child.append_tags(rule["add_tags"]) - if "remove_tags" in rule: - child.remove_tags(rule["remove_tags"]) - - def depth(self) -> int: - """Returns the distance to the root HConfig object i.e. indent level""" + def depth(self) -> int: # noqa: PLR6301 + """Returns the distance to the root HConfig object i.e. indent level.""" return 0 def difference(self, target: HConfig) -> HConfig: - """ - Creates a new HConfig object with the config from self that is not in target - - Example usage: - whats in the config.lines v.s. in running config - i.e. did all my configuration changes get written to the running config - - :param target: HConfig - The configuration to check against - :return: HConfig - missing config additions - """ - delta = HConfig(host=self.host) + """Creates a new HConfig object with the config from self that is not in target.""" + delta = HConfig(self.driver) difference = self._difference(target, delta) # Makes mypy happy if not isinstance(difference, HConfig): @@ -233,29 +158,31 @@ def difference(self, target: HConfig) -> HConfig: return difference def config_to_get_to( - self, target: HConfig, delta: Optional[HConfig] = None + self, + target: HConfig, + delta: Optional[HConfig] = None, ) -> HConfig: - """ - Figures out what commands need to be executed to transition from self to target. + """Figures out what commands need to be executed to transition from self to target. self is the source data structure(i.e. the running_config), - target is the destination(i.e. generated_config) + target is the destination(i.e. generated_config). """ if delta is None: - delta = HConfig(host=self.host) + delta = HConfig(self.driver) root_config = self._config_to_get_to(target, delta) + # Makes mypy happy if not isinstance(root_config, HConfig): raise TypeError return root_config def add_ancestor_copy_of( - self, parent_to_add: HConfigChild + self, + parent_to_add: HConfigChild, ) -> Union[HConfig, HConfigChild]: - """ - Add a copy of the ancestry of parent_to_add to self - and return the deepest child which is equivalent to parent_to_add + """Add a copy of the ancestry of parent_to_add to self + and return the deepest child which is equivalent to parent_to_add. """ base: Union[HConfig, HConfigChild] = self for parent in parent_to_add.lineage(): @@ -263,224 +190,50 @@ def add_ancestor_copy_of( return base - def set_order_weight(self) -> None: - """Sets self.order integer on all children""" - for child in self.all_children(): - for rule in self.options["ordering"]: - if child.lineage_test(rule): - child.order_weight = rule["order"] - - def add_sectional_exiting(self) -> None: - """ - Adds the sectional exiting text as a child - """ + def set_order_weight(self) -> HConfig: + """Sets self.order integer on all children.""" for child in self.all_children(): - for rule in self.options["sectional_exiting"]: - if child.lineage_test(rule): - exit_line = child.get_child("equals", rule["exit_text"]) - if exit_line is None: - exit_line = child.add_child(rule["exit_text"]) - - exit_line.tags = child.tags - exit_line.order_weight = 999 + for rule in self.driver.rules.ordering: + if child.is_lineage_match(rule.match_rules): + child.order_weight = rule.weight + return self def future(self, config: HConfig) -> HConfig: - """ - EXPERIMENTAL - predict the future config after config is applied to self + """EXPERIMENTAL - predict the future config after config is applied to self. - The quality of the this method's output will in part depend on how well + The quality of this method's output will in part depend on how well the OS options are tuned. Ensuring that idempotency rules are accurate is especially important. """ - future_config = HConfig(host=self.host) + future_config = HConfig(self.driver) self._future(config, future_config) return future_config - def with_tags(self, tags: Set[str]) -> HConfig: - """ - Returns a new instance containing only sub-objects - with one of the tags in tags - """ - new_instance = HConfig(self.host) - result = self._with_tags(tags, new_instance) + def with_tags(self, tags: Iterable[str]) -> HConfig: + """Returns a new instance recursively containing children that only have a subset of tags.""" + new_instance = HConfig(self.driver) + result = self._with_tags(frozenset(tags), new_instance) # Makes mypy happy if not isinstance(result, HConfig): - raise ValueError + raise TypeError return new_instance def all_children_sorted_by_tags( - self, include_tags: Set[str], exclude_tags: Set[str] + self, + include_tags: Iterable[str], + exclude_tags: Iterable[str], ) -> Iterator[HConfigChild]: - """Yield all children recursively that match include/exclude tags""" + """Yield all children recursively that match include/exclude tags.""" for child in sorted(self.children): yield from child.all_children_sorted_by_tags(include_tags, exclude_tags) - @staticmethod - def _load_from_string_lines_end_of_banner_test( - config_line: str, banner_end_lines: Set[str], banner_end_contains: List[str] - ) -> bool: - if config_line.startswith("^"): - return True - if config_line in banner_end_lines: - return True - if any([c in config_line for c in banner_end_contains]): - return True - return False - - # pylint: disable=too-many-locals,too-many-branches,too-many-statements - def _load_from_string_lines(self, config_text: str) -> None: - current_section: Union[HConfig, HConfigChild] = self - most_recent_item: Union[HConfig, HConfigChild] = current_section - indent_adjust = 0 - end_indent_adjust = [] - temp_banner = [] - banner_end_lines = {"EOF", "%", "!"} - banner_end_contains: List[str] = [] - in_banner = False - - for line in config_text.splitlines(): - # Process banners in configuration into one line - if in_banner: - if line != "!": - temp_banner.append(line) - - # Test if this line is the end of a banner - if self._load_from_string_lines_end_of_banner_test( - str(line), banner_end_lines, banner_end_contains - ): - in_banner = False - most_recent_item = self.add_child("\n".join(temp_banner), True) - most_recent_item.real_indent_level = 0 - current_section = self - temp_banner = [] - continue - - # Test if this line is the start of a banner and not an empty banner - # Empty banners matching the below expression have been seen on NX-OS - if line.startswith("banner ") and line != "banner motd ##": - in_banner = True - temp_banner.append(line) - banner_words = line.split() - try: - banner_end_contains.append(banner_words[2]) - banner_end_lines.add(banner_words[2][:1]) - banner_end_lines.add(banner_words[2][:2]) - except IndexError: - pass - continue - - actual_indent = len(line) - len(line.lstrip()) - line = " " * actual_indent + " ".join(line.split()) - for sub in self.options["per_line_sub"]: - line = re.sub(sub["search"], sub["replace"], line) - line = line.rstrip() - - # If line is now empty, move to the next - if not line: - continue - - # Determine indentation level - this_indent = len(line) - len(line.lstrip()) + indent_adjust - - line = line.lstrip() - - # Walks back up the tree - while this_indent <= current_section.real_indent_level: - current_section = current_section.parent - - # Walks down the tree by one step - if this_indent > most_recent_item.real_indent_level: - current_section = most_recent_item - - most_recent_item = current_section.add_child(line, True) - most_recent_item.real_indent_level = this_indent - - for expression in self.options["indent_adjust"]: - if re.search(expression["start_expression"], line): - indent_adjust += 1 - end_indent_adjust.append(expression["end_expression"]) - break - if end_indent_adjust and re.search(end_indent_adjust[0], line): - indent_adjust -= 1 - del end_indent_adjust[0] - assert not in_banner, "we are still in a banner for some reason" - - def _add_acl_sequence_numbers(self) -> None: - """ - Add ACL sequence numbers for use on configurations with a style of 'ios' - """ - ipv4_acl_sw = "ip access-list" - # ipv6_acl_sw = ('ipv6 access-list') - if self.host.os in ["ios"]: - acl_line_sw: Tuple[str, ...] = ("permit", "deny") - else: - acl_line_sw = ("permit", "deny", "remark") + def deep_copy(self) -> HConfig: + """Return a copy of this object.""" + new_instance = HConfig(self.driver) for child in self.children: - if child.text.startswith(ipv4_acl_sw): - sequence_number = 10 - for sub_child in child.children: - if sub_child.text.startswith(acl_line_sw): - sub_child.text = f"{sequence_number} {sub_child.text}" - sequence_number += 10 - - def _rm_ipv6_acl_sequence_numbers(self) -> None: - """If there are sequence numbers in the IPv6 ACL, remove them""" - for acl in self.get_children("startswith", "ipv6 access-list "): - for entry in acl.children: - if entry.text.startswith("sequence"): - entry.text = " ".join(entry.text.split()[2:]) - - def _remove_acl_remarks(self) -> None: - for acl in self.get_children("startswith", "ip access-list "): - for entry in acl.children: - if entry.text.startswith("remark"): - acl.children.remove(entry) - - def _duplicate_child_allowed_check(self) -> bool: - """Determine if duplicate(identical text) children are allowed under the parent""" - return False + new_instance.add_deep_copy_of(child) + return new_instance - def _convert_to_set_commands(self, config_str: str) -> str: - """ - Convert a Juniper style config string into a list of set commands. - Args: - config_str (str): The config string to convert to set commands - Returns: - config_str (str): Configuration string - """ - lines = [] - path = [] - for line in config_str.splitlines(): - stripped_line = line.strip() - - # Skip empty lines - if not stripped_line: - continue - - # Strip ; from the end of the line - stripped_line = stripped_line.rstrip(";") - - # Skip comments - if stripped_line.startswith(("/*", "#")): - continue - - # Handle block start / end - if stripped_line.endswith("{"): - path.append(stripped_line.rstrip("{").strip()) - continue - if stripped_line.endswith("}"): - try: - path.pop() - continue - except IndexError as e: - raise ValueError("unexpected extra end of block '}'") from e - - # If it's not already a set command, build it - if not stripped_line.startswith(("set", self.options["negation"])): - stripped_line = "set " + " ".join(path) + " " + stripped_line - lines.append(stripped_line) - - if path: - raise ValueError("unterminated configuration: missing '}'?") - - return "\n".join(lines) + def _is_duplicate_child_allowed(self) -> bool: # noqa: PLR6301 + """Determine if duplicate(identical text) children are allowed under the parent.""" + return False diff --git a/hier_config/text_match.py b/hier_config/text_match.py deleted file mode 100644 index dc511b4..0000000 --- a/hier_config/text_match.py +++ /dev/null @@ -1,57 +0,0 @@ -import re -from typing import Tuple, Union, Set - - -def equals(text: str, expression: Union[str, Set[str]]) -> bool: - """Text equivalence test""" - if isinstance(expression, str): - return text == expression - return text in expression - - -def startswith(text: str, expression: Union[str, Tuple[str, ...]]) -> bool: - """Text starts with test""" - return text.startswith(expression) - - -def endswith(text: str, expression: Union[str, Tuple[str, ...]]) -> bool: - """Text ends with test""" - return text.endswith(expression) - - -def contains(text: str, expression: str) -> bool: - """Text contains test""" - return expression in text - - -def anything(text: str, expression: str) -> bool: # pylint: disable=unused-argument - """Always returns True""" - return True - - -def nothing(text: str, expression: str) -> bool: # pylint: disable=unused-argument - """Always returns False""" - return False - - -def re_search(text: str, expression: str) -> bool: - """ - Test regex match. This method is comparatively - very slow and should be avoided where possible. - """ - return re.search(expression, text) is not None - - -def dict_call(test: str, text: str, expression: str) -> bool: - """ - Allows test methods to be called easily from variables - """ - return { - "equals": equals, - "startswith": startswith, - "endswith": endswith, - "contains": contains, - "re_search": re_search, - "anything": anything, - "nothing": nothing, - }[test](text, expression) diff --git a/hier_config/workflows.py b/hier_config/workflows.py new file mode 100644 index 0000000..62136d5 --- /dev/null +++ b/hier_config/workflows.py @@ -0,0 +1,157 @@ +from collections.abc import Iterable +from logging import getLogger +from typing import Optional + +from .models import TagRule +from .root import HConfig + +logger = getLogger(__name__) + + +class WorkflowRemediation: + """Manages configuration workflows for a network device by comparing + running and generated configurations and creating remediations to align + the device with the intended configuration state. + + Attributes: + running_config (HConfig): The current configuration of the network device. + generated_config (HConfig): The target configuration for the network device. + + Raises: + ValueError: If `running_config` and `generated_config` have different drivers. + + Example: + Initialize `WorkflowRemediation` with the running and generated configurations + and generate remediation and rollback configurations. + + ```python + from hier_config import WorkflowRemediation, get_hconfig + from hier_config.model import Platform + + # Create running and generated configurations as HConfig objects + running_config = get_hconfig(Platform.CISCO_IOS, "running_config_text") + generated_config = get_hconfig(Platform.CISCO_IOS, "generated_config_text") + + # Initialize WorkflowRemediation with running and generated configurations + workflow = WorkflowRemediation(running_config, generated_config) + + # Generate the remediation configuration to apply the target configuration to the device + remediation_config = workflow.remediation_config + print("Remediation configuration:") + for line in remediation_config.all_children_sorted(): + print(line.cisco_style_text()) + + # Generate the rollback configuration to revert back to the running configuration + rollback_config = workflow.rollback_config + print("Rollback configuration:") + for line in rollback_config.all_children_sorted(): + print(line.cisco_style_text()) + ``` + + """ + + def __init__( + self, + running_config: HConfig, + generated_config: HConfig, + ) -> None: + self.running_config = running_config + self.generated_config = generated_config + + if running_config.driver.__class__ is not generated_config.driver.__class__: + message = "The running and generated configs must use the same driver." + raise ValueError(message) + + self._remediation_config: Optional[HConfig] = None + self._rollback_config: Optional[HConfig] = None + + @property + def remediation_config(self) -> HConfig: + """Builds and returns the remediation configuration to bring the device + in line with the generated configuration. + + Returns: + HConfig: The configuration needed to remediate the device. + + Notes: + The remediation configuration is cached after the first call. + + """ + if self._remediation_config: + return self._remediation_config + + remediation_config = self.running_config.config_to_get_to( + self.generated_config, HConfig(self.running_config.driver) + ).set_order_weight() + + self._remediation_config = remediation_config + + return self._remediation_config + + @property + def rollback_config(self) -> HConfig: + """Builds and returns the rollback configuration to revert the device + from the generated configuration back to the running configuration. + + Returns: + HConfig: The configuration required to roll back to the original state. + + Notes: + The rollback configuration is cached after the first call. + + """ + if self._rollback_config: + return self._rollback_config + + rollback_config = self.generated_config.config_to_get_to( + self.running_config, HConfig(self.running_config.driver) + ).set_order_weight() + + self._rollback_config = rollback_config + + return rollback_config + + def apply_remediation_tag_rules(self, tag_rules: tuple[TagRule, ...]) -> None: + """Applies tag rules to selectively label parts of the remediation configuration. + + Args: + tag_rules (tuple[TagRule, ...]): A set of tag rules specifying sections to tag. + + Notes: + This method is useful for managing configuration changes by marking specific + parts of the config for conditional remediation. + + """ + for tag_rule in tag_rules: + for child in self.remediation_config.get_children_deep( + tag_rule.match_rules + ): + child.tags_add(tag_rule.apply_tags) + + def remediation_config_filtered_text( + self, + include_tags: Iterable[str] = (), + exclude_tags: Iterable[str] = (), + ) -> str: + """Returns the remediation configuration as text, filtered by included and excluded tags. + + Args: + include_tags (Iterable[str], optional): Tags to include in the output. + exclude_tags (Iterable[str], optional): Tags to exclude from the output. + + Returns: + str: The filtered remediation configuration in a text format. + + Notes: + - If no tags are provided, the complete sorted remediation configuration is returned. + - Sorting respects configuration hierarchy and specified tags. + + """ + children = ( + self.remediation_config.all_children_sorted_by_tags( + include_tags, exclude_tags + ) + if include_tags or exclude_tags + else self.remediation_config.all_children_sorted() + ) + return "\n".join(c.cisco_style_text() for c in children) diff --git a/mkdocs.yml b/mkdocs.yml index 489c896..c829397 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,4 +9,4 @@ nav: - Install: install.md - Getting Started: getting-started.md - Advanced Topics: advanced-topics.md -- Experimental Features: experimental-features.md \ No newline at end of file +- Experimental Features: experimental-features.md diff --git a/poetry.lock b/poetry.lock index 9d71efc..b99195c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,84 +1,56 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] -name = "astroid" -version = "3.0.2" -description = "An abstract syntax tree for Python with inference support." -category = "dev" +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.8" files = [ - {file = "astroid-3.0.2-py3-none-any.whl", hash = "sha256:d6e62862355f60e716164082d6b4b041d38e2a8cf1c7cd953ded5108bac8ff5c"}, - {file = "astroid-3.0.2.tar.gz", hash = "sha256:4a61cf0a59097c7bb52689b0fd63717cd2a8a14dc9f1eee97b82d814881c8c91"}, + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} +[[package]] +name = "astor" +version = "0.8.1" +description = "Read/rewrite/write Python ASTs" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +files = [ + {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, + {file = "astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e"}, +] [[package]] -name = "black" -version = "23.12.1" -description = "The uncompromising code formatter." -category = "dev" +name = "astroid" +version = "3.3.5" +description = "An abstract syntax tree for Python with inference support." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9.0" files = [ - {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, - {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, - {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, - {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, - {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, - {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, - {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, - {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, - {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, - {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, - {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, - {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, - {file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"}, - {file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"}, - {file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"}, - {file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"}, - {file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"}, - {file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"}, - {file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"}, - {file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"}, - {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, - {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, + {file = "astroid-3.3.5-py3-none-any.whl", hash = "sha256:a9d1c946ada25098d790e079ba2a1b112157278f3fb7e718ae6a9252f5835dc8"}, + {file = "astroid-3.3.5.tar.gz", hash = "sha256:5cfc40ae9f68311075d27ef68a4841bdc5cc7f6cf86671b49f00607d30188e2d"}, ] [package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} [[package]] name = "bracex" -version = "2.4" +version = "2.5.post1" description = "Bash style brace expander." -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "bracex-2.4-py3-none-any.whl", hash = "sha256:efdc71eff95eaff5e0f8cfebe7d01adf2c8637c8c92edaf63ef348c241a82418"}, - {file = "bracex-2.4.tar.gz", hash = "sha256:a27eaf1df42cf561fed58b7a8f3fdf129d1ea16a81e1fadd1d17989bc6384beb"}, + {file = "bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6"}, + {file = "bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6"}, ] [[package]] name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -93,7 +65,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -103,64 +74,73 @@ files = [ [[package]] name = "coverage" -version = "7.4.0" +version = "7.6.7" description = "Code coverage measurement for Python" -category = "dev" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, - {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, - {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, - {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, - {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, + {file = "coverage-7.6.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:108bb458827765d538abcbf8288599fee07d2743357bdd9b9dad456c287e121e"}, + {file = "coverage-7.6.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c973b2fe4dc445cb865ab369df7521df9c27bf40715c837a113edaa2aa9faf45"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c6b24007c4bcd0b19fac25763a7cac5035c735ae017e9a349b927cfc88f31c1"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acbb8af78f8f91b3b51f58f288c0994ba63c646bc1a8a22ad072e4e7e0a49f1c"}, + {file = "coverage-7.6.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad32a981bcdedb8d2ace03b05e4fd8dace8901eec64a532b00b15217d3677dd2"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:34d23e28ccb26236718a3a78ba72744212aa383141961dd6825f6595005c8b06"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e25bacb53a8c7325e34d45dddd2f2fbae0dbc230d0e2642e264a64e17322a777"}, + {file = "coverage-7.6.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af05bbba896c4472a29408455fe31b3797b4d8648ed0a2ccac03e074a77e2314"}, + {file = "coverage-7.6.7-cp310-cp310-win32.whl", hash = "sha256:796c9b107d11d2d69e1849b2dfe41730134b526a49d3acb98ca02f4985eeff7a"}, + {file = "coverage-7.6.7-cp310-cp310-win_amd64.whl", hash = "sha256:987a8e3da7da4eed10a20491cf790589a8e5e07656b6dc22d3814c4d88faf163"}, + {file = "coverage-7.6.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7e61b0e77ff4dddebb35a0e8bb5a68bf0f8b872407d8d9f0c726b65dfabe2469"}, + {file = "coverage-7.6.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a5407a75ca4abc20d6252efeb238377a71ce7bda849c26c7a9bece8680a5d99"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df002e59f2d29e889c37abd0b9ee0d0e6e38c24f5f55d71ff0e09e3412a340ec"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:673184b3156cba06154825f25af33baa2671ddae6343f23175764e65a8c4c30b"}, + {file = "coverage-7.6.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69ad502f1a2243f739f5bd60565d14a278be58be4c137d90799f2c263e7049a"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60dcf7605c50ea72a14490d0756daffef77a5be15ed1b9fea468b1c7bda1bc3b"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9c2eb378bebb2c8f65befcb5147877fc1c9fbc640fc0aad3add759b5df79d55d"}, + {file = "coverage-7.6.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c0317288f032221d35fa4cbc35d9f4923ff0dfd176c79c9b356e8ef8ef2dff4"}, + {file = "coverage-7.6.7-cp311-cp311-win32.whl", hash = "sha256:951aade8297358f3618a6e0660dc74f6b52233c42089d28525749fc8267dccd2"}, + {file = "coverage-7.6.7-cp311-cp311-win_amd64.whl", hash = "sha256:5e444b8e88339a2a67ce07d41faabb1d60d1004820cee5a2c2b54e2d8e429a0f"}, + {file = "coverage-7.6.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f07ff574986bc3edb80e2c36391678a271d555f91fd1d332a1e0f4b5ea4b6ea9"}, + {file = "coverage-7.6.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:49ed5ee4109258973630c1f9d099c7e72c5c36605029f3a91fe9982c6076c82b"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3e8796434a8106b3ac025fd15417315d7a58ee3e600ad4dbcfddc3f4b14342c"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3b925300484a3294d1c70f6b2b810d6526f2929de954e5b6be2bf8caa1f12c1"}, + {file = "coverage-7.6.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c42ec2c522e3ddd683dec5cdce8e62817afb648caedad9da725001fa530d354"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0266b62cbea568bd5e93a4da364d05de422110cbed5056d69339bd5af5685433"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e5f2a0f161d126ccc7038f1f3029184dbdf8f018230af17ef6fd6a707a5b881f"}, + {file = "coverage-7.6.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132b5a22821f9b143f87446805e13580b67c670a548b96da945a8f6b4f2efbb"}, + {file = "coverage-7.6.7-cp312-cp312-win32.whl", hash = "sha256:7c07de0d2a110f02af30883cd7dddbe704887617d5c27cf373362667445a4c76"}, + {file = "coverage-7.6.7-cp312-cp312-win_amd64.whl", hash = "sha256:fd49c01e5057a451c30c9b892948976f5d38f2cbd04dc556a82743ba8e27ed8c"}, + {file = "coverage-7.6.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3"}, + {file = "coverage-7.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc"}, + {file = "coverage-7.6.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55"}, + {file = "coverage-7.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384"}, + {file = "coverage-7.6.7-cp313-cp313-win32.whl", hash = "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30"}, + {file = "coverage-7.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42"}, + {file = "coverage-7.6.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413"}, + {file = "coverage-7.6.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b"}, + {file = "coverage-7.6.7-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b"}, + {file = "coverage-7.6.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3"}, + {file = "coverage-7.6.7-cp313-cp313t-win32.whl", hash = "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8"}, + {file = "coverage-7.6.7-cp313-cp313t-win_amd64.whl", hash = "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56"}, + {file = "coverage-7.6.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37a15573f988b67f7348916077c6d8ad43adb75e478d0910957394df397d2874"}, + {file = "coverage-7.6.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6cce5c76985f81da3769c52203ee94722cd5d5889731cd70d31fee939b74bf0"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ab9763d291a17b527ac6fd11d1a9a9c358280adb320e9c2672a97af346ac2c"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cf96ceaa275f071f1bea3067f8fd43bec184a25a962c754024c973af871e1b7"}, + {file = "coverage-7.6.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee9cf6b0134d6f932d219ce253ef0e624f4fa588ee64830fcba193269e4daa3"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2bc3e45c16564cc72de09e37413262b9f99167803e5e48c6156bccdfb22c8327"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:623e6965dcf4e28a3debaa6fcf4b99ee06d27218f46d43befe4db1c70841551c"}, + {file = "coverage-7.6.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850cfd2d6fc26f8346f422920ac204e1d28814e32e3a58c19c91980fa74d8289"}, + {file = "coverage-7.6.7-cp39-cp39-win32.whl", hash = "sha256:c296263093f099da4f51b3dff1eff5d4959b527d4f2f419e16508c5da9e15e8c"}, + {file = "coverage-7.6.7-cp39-cp39-win_amd64.whl", hash = "sha256:90746521206c88bdb305a4bf3342b1b7316ab80f804d40c536fc7d329301ee13"}, + {file = "coverage-7.6.7-pp39.pp310-none-any.whl", hash = "sha256:0ddcb70b3a3a57581b450571b31cb774f23eb9519c2aaa6176d3a84c9fc57671"}, + {file = "coverage-7.6.7.tar.gz", hash = "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24"}, ] [package.dependencies] @@ -171,56 +151,69 @@ toml = ["tomli"] [[package]] name = "dill" -version = "0.3.7" +version = "0.3.9" description = "serialize all of Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"}, - {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"}, + {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"}, + {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"}, ] [package.extras] graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] -name = "flake8" -version = "7.0.0" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" optional = false -python-versions = ">=3.8.1" +python-versions = ">=3.8" files = [ - {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, - {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "flynt" +version = "1.0.1" +description = "CLI tool to convert a python project's %-formatted strings to f-strings." +optional = false +python-versions = ">=3.7" +files = [ + {file = "flynt-1.0.1-py3-none-any.whl", hash = "sha256:65d1c546434827275123222a98408e9561bcd67db832dd58f530ff17b8329ec1"}, + {file = "flynt-1.0.1.tar.gz", hash = "sha256:988aac00672a5469726cc0a17cef7d1178c284a9fe8563458db2475d0aaed965"}, ] [package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.11.0,<2.12.0" -pyflakes = ">=3.2.0,<3.3.0" +astor = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["build", "pre-commit", "pytest", "pytest-cov", "twine"] [[package]] name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." -category = "dev" optional = false python-versions = "*" files = [ @@ -234,31 +227,44 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["flake8", "markdown", "twine", "wheel"] +[[package]] +name = "gprof2dot" +version = "2024.6.6" +description = "Generate a dot graph from the output of several profilers." +optional = false +python-versions = ">=3.8" +files = [ + {file = "gprof2dot-2024.6.6-py2.py3-none-any.whl", hash = "sha256:45b14ad7ce64e299c8f526881007b9eb2c6b75505d5613e96e66ee4d5ab33696"}, + {file = "gprof2dot-2024.6.6.tar.gz", hash = "sha256:fa1420c60025a9eb7734f65225b4da02a10fc6dd741b37fa129bc6b41951e5ab"}, +] + [[package]] name = "importlib-metadata" -version = "7.0.1" +version = "8.5.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, ] [package.dependencies] -zipp = ">=0.5" +zipp = ">=3.20" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -270,7 +276,6 @@ files = [ name = "isort" version = "5.13.2" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -283,14 +288,13 @@ colors = ["colorama (>=0.4.6)"] [[package]] name = "jinja2" -version = "3.1.3" +version = "3.1.4" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] [package.dependencies] @@ -301,14 +305,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown" -version = "3.5.2" +version = "3.7" description = "Python implementation of John Gruber's Markdown." -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, - {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] [package.dependencies] @@ -318,71 +321,104 @@ importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" -version = "2.1.3" +version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -390,11 +426,21 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -404,99 +450,119 @@ files = [ [[package]] name = "mkdocs" -version = "1.5.3" +version = "1.6.1" description = "Project documentation with Markdown." -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, - {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, ] [package.dependencies] click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" -markdown = ">=3.2.1" +markdown = ">=3.3.6" markupsafe = ">=2.0.1" mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" packaging = ">=20.5" pathspec = ">=0.11.1" -platformdirs = ">=2.2.0" pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" [[package]] name = "mkdocs-include-markdown-plugin" -version = "6.0.4" +version = "7.0.0" description = "Mkdocs Markdown includer plugin." -category = "dev" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "mkdocs_include_markdown_plugin-6.0.4-py3-none-any.whl", hash = "sha256:e7b8b5ecc41d6a3e16969cff3725ec3a391b68e9dfe1a4b4e36a8508becda835"}, - {file = "mkdocs_include_markdown_plugin-6.0.4.tar.gz", hash = "sha256:523c9c3a1d6a517386dc11bf60b0c0c564af1071bb6de8d213106d54f752dcc1"}, + {file = "mkdocs_include_markdown_plugin-7.0.0-py3-none-any.whl", hash = "sha256:bf8d19245ae3fb2eea395888e80c60bc91806a0d879279707d707896c24319c3"}, + {file = "mkdocs_include_markdown_plugin-7.0.0.tar.gz", hash = "sha256:a8eac8f2e6aa391d82d1d5e473b819b52393d91464060c02db5741834fe9008b"}, ] [package.dependencies] mkdocs = ">=1.4" -wcmatch = ">=8,<9" +wcmatch = "*" [package.extras] cache = ["platformdirs"] [[package]] name = "mypy" -version = "1.8.0" +version = "1.13.0" description = "Optional static typing for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" +typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -505,7 +571,6 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -513,23 +578,32 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "packaging" -version = "23.2" +version = "24.2" description = "Core utilities for Python packages" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -539,30 +613,29 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -570,48 +643,161 @@ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] -name = "pycodestyle" -version = "2.11.1" -description = "Python style guide checker" -category = "dev" +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, - {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, ] +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" -category = "dev" +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pylint" -version = "3.0.3" +version = "3.3.1" description = "python code static checker" -category = "dev" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" files = [ - {file = "pylint-3.0.3-py3-none-any.whl", hash = "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810"}, - {file = "pylint-3.0.3.tar.gz", hash = "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b"}, + {file = "pylint-3.3.1-py3-none-any.whl", hash = "sha256:2f846a466dd023513240bc140ad2dd73bfc080a5d85a710afdb728c420a5a2b9"}, + {file = "pylint-3.3.1.tar.gz", hash = "sha256:9f3dcc87b1203e612b78d91a896407787e708b3f189b5fa0b307712d49ff0c6e"}, ] [package.dependencies] -astroid = ">=3.0.1,<=3.1.0-dev0" +astroid = ">=3.3.4,<=3.4.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" mccabe = ">=0.6,<0.8" @@ -624,16 +810,64 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pylint-plugin-utils" +version = "0.8.2" +description = "Utilities and helpers for writing Pylint plugins" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pylint_plugin_utils-0.8.2-py3-none-any.whl", hash = "sha256:ae11664737aa2effbf26f973a9e0b6779ab7106ec0adc5fe104b0907ca04e507"}, + {file = "pylint_plugin_utils-0.8.2.tar.gz", hash = "sha256:d3cebf68a38ba3fba23a873809155562571386d4c1b03e5b4c4cc26c3eee93e4"}, +] + +[package.dependencies] +pylint = ">=1.7" + +[[package]] +name = "pylint-pydantic" +version = "0.3.2" +description = "A Pylint plugin to help Pylint understand the Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pylint_pydantic-0.3.2-py3-none-any.whl", hash = "sha256:e5cec02370aa68ac8eff138e5d573b0ac049bab864e9a6c3a9057cf043440aa1"}, +] + +[package.dependencies] +pydantic = "<3.0" +pylint = ">2.0,<4.0" +pylint-plugin-utils = "*" + +[[package]] +name = "pyright" +version = "1.1.389" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60"}, + {file = "pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + [[package]] name = "pytest" -version = "7.4.4" +version = "8.3.3" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -641,52 +875,53 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] -name = "pytest-black" -version = "0.3.12" -description = "A pytest plugin to enable format checking with black" -category = "dev" +name = "pytest-cov" +version = "6.0.0" +description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=2.7" +python-versions = ">=3.9" files = [ - {file = "pytest-black-0.3.12.tar.gz", hash = "sha256:1d339b004f764d6cd0f06e690f6dd748df3d62e6fe1a692d6a5500ac2c5b75a5"}, + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, ] [package.dependencies] -black = {version = "*", markers = "python_version >= \"3.6\""} -pytest = ">=3.5.0" -toml = "*" +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -category = "dev" +name = "pytest-profiling" +version = "1.7.0" +description = "Profiling plugin for py.test" optional = false -python-versions = ">=3.7" +python-versions = "*" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-profiling-1.7.0.tar.gz", hash = "sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29"}, + {file = "pytest_profiling-1.7.0-py2.py3-none-any.whl", hash = "sha256:999cc9ac94f2e528e3f5d43465da277429984a1c237ae9818f8cfd0b06acb019"}, ] [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" +gprof2dot = "*" +pytest = "*" +six = "*" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +tests = ["pytest-virtualenv"] [[package]] name = "pytest-runner" version = "6.0.1" description = "Invoke py.test as distutils command with dependency resolution" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -698,16 +933,35 @@ files = [ docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-virtualenv", "types-setuptools"] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] @@ -715,69 +969,70 @@ six = ">=1.5" [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -788,11 +1043,67 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.7.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, + {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, + {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, + {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, + {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, + {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, + {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, + {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, + {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -801,100 +1112,103 @@ files = [ ] [[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" +name = "tomli" +version = "2.1.0" +description = "A lil' TOML parser" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.8" files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, + {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, + {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, ] [[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, ] [[package]] -name = "tomlkit" -version = "0.12.3" -description = "Style preserving TOML library" -category = "dev" +name = "typer" +version = "0.13.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, - {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, + {file = "typer-0.13.0-py3-none-any.whl", hash = "sha256:d85fe0b777b2517cc99c8055ed735452f2659cd45e451507c76f48ce5c1d00e2"}, + {file = "typer-0.13.0.tar.gz", hash = "sha256:f1c7198347939361eec90139ffa0fd8b3df3a2259d5852a0f7400e476d95985c"}, ] +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "types-pyyaml" -version = "6.0.12.12" +version = "6.0.12.20240917" description = "Typing stubs for PyYAML" -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, - {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, + {file = "types-PyYAML-6.0.12.20240917.tar.gz", hash = "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587"}, + {file = "types_PyYAML-6.0.12.20240917-py3-none-any.whl", hash = "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570"}, ] [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] name = "watchdog" -version = "3.0.0" +version = "6.0.0" description = "Filesystem events monitoring" -category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, ] [package.extras] @@ -902,36 +1216,56 @@ watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "wcmatch" -version = "8.5" +version = "10.0" description = "Wildcard/glob file name matcher." -category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "wcmatch-8.5-py3-none-any.whl", hash = "sha256:14554e409b142edeefab901dc68ad570b30a72a8ab9a79106c5d5e9a6d241bd5"}, - {file = "wcmatch-8.5.tar.gz", hash = "sha256:86c17572d0f75cbf3bcb1a18f3bf2f9e72b39a9c08c9b4a74e991e1882a8efb3"}, + {file = "wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a"}, + {file = "wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a"}, ] [package.dependencies] bracex = ">=2.1.1" +[[package]] +name = "yamllint" +version = "1.35.1" +description = "A linter for YAML files." +optional = false +python-versions = ">=3.8" +files = [ + {file = "yamllint-1.35.1-py3-none-any.whl", hash = "sha256:2e16e504bb129ff515b37823b472750b36b6de07963bd74b307341ef5ad8bdc3"}, + {file = "yamllint-1.35.1.tar.gz", hash = "sha256:7a003809f88324fd2c877734f2d575ee7881dd9043360657cc8049c809eba6cd"}, +] + +[package.dependencies] +pathspec = ">=0.5.3" +pyyaml = "*" + +[package.extras] +dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"] + [[package]] name = "zipp" -version = "3.17.0" +version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] [metadata] lock-version = "2.0" -python-versions = ">=3.8.1,<4.0" -content-hash = "890c0b4bbe492e9304ca51833faecf75066e0224d5d5f1b511cf9f7dcba5b024" +python-versions = ">=3.9.0,<4.0" +content-hash = "2dd923fecc8838cf2d3fb9b513937a4f8bdb551dad3d9e433eedca78bb1746be" diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 2f0198f..0000000 --- a/pylintrc +++ /dev/null @@ -1,8 +0,0 @@ -[MESSAGES CONTROL] - -disable=invalid-name, - missing-docstring, - # remove cyclic-import once https://github.com/PyCQA/pylint/issues/3525 is closed - cyclic-import, - unsubscriptable-object, - use-a-generator, diff --git a/pyproject.toml b/pyproject.toml index 53bd501..6f01caf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "hier-config" -version = "2.3.0" -description = "A network configuration comparison tool, used to build remediation configurations." +version = "3.0.0" +description = "A network configuration query and comparison library, used to build remediation configurations." packages = [ { include="hier_config", from="."}, ] @@ -18,30 +18,138 @@ classifiers = [ "Intended Audience :: System Administrators", "Intended Audience :: Telecommunications Industry", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - # "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Natural Language :: English", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Networking", ] [tool.poetry.dependencies] -python = ">=3.8.1,<4.0" -PyYAML = ">= 5.4" -types-pyyaml = "^6.0.12.12" +python = ">=3.9.0,<4.0" +pydantic = "^2.9.2" [tool.poetry.group.dev.dependencies] -black = "^23.12.1" -flake8 = "^7.0.0" -pytest = "^7.4.4" -mypy = "^1.8.0" -pylint = "^3.0.3" -pytest-cov = "^4.1.0" -pytest-black = "^0.3.12" -pytest-runner = "^6.0.1" -mkdocs = "^1.5.3" -mkdocs-include-markdown-plugin = "^6.0.4" +flynt = "*" +mkdocs = "*" +mkdocs-include-markdown-plugin = "*" +mypy = "*" +pylint = "*" +pylint-pydantic = "*" +pyright = "*" +pytest = "*" +pytest-cov = "*" +pytest-profiling = "*" +pytest-runner = "*" +pytest-xdist = "*" +ruff = "*" +typer = "*" +types-pyyaml = "*" +yamllint = "*" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + + +[tool.pyright] +typeCheckingMode = "strict" + + +[tool.pylint.message_control] +load-plugins = [ + "pylint.extensions.bad_builtin", + "pylint.extensions.broad_try_clause", + "pylint.extensions.check_elif", + "pylint.extensions.comparison_placement", + "pylint.extensions.confusing_elif", + "pylint.extensions.consider_ternary_expression", + "pylint.extensions.dict_init_mutate", + "pylint.extensions.dunder", + "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.set_membership", + "pylint.extensions.typing", + "pylint_pydantic", +] +disable = [ + "consider-alternative-union-syntax", + "duplicate-code", # Enable this at some point in the future + "fixme", # Covered by ruff FIX002 + "import-outside-toplevel", # Covered by ruff PLC0415 + "line-too-long", # Whatever ruff format determines the line should look like is fine + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "protected-access", # Covered by ruff SLF001 + "redefined-loop-name", # Covered by ruff PLW2901 + "too-many-arguments", # Covered by ruff PLR0913 + "too-many-return-statements", # Covered by ruff PLR0911 + "too-many-locals", # Coverred by ruff PLR0914 + "too-many-public-methods", # Coverred by ruff PLR0904 +] + + +[tool.mypy] +strict = true +pretty = true +show_column_numbers = true +show_error_codes = true +show_error_context = true +warn_unreachable = true +plugins = [ + "pydantic.mypy" +] + + +[tool.ruff] +line-length = 88 +target-version = "py39" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = ["ALL"] +preview = true +ignore = [ + # The below checks we might never enable + "B019", # using lru_cache can cause memory leaks + "COM812", # DO NOT REMOVE: conflicts with ruff formater + "CPY001", # Missing copyright notice at top of file + "D213", # DO NOT REMOVE: multi-line-summary-second-line is incompatible with D212(multi-line-summary-first-line) + "E501", # line too long + "EXE002", # DO NOT REMOVE: The file is executable but no shebang is present + "ISC001", # DO NOT REMOVE: The following rule may cause conflicts when used with the formatter + "PLR2004", # Magic value used in comparison + # The below checks should eventually be enabled + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "D200", # One-line docstring should fit on one line + "D203", # 1 blank line required before class docstring - Incompatible with D211 + "D205", # 1 blank line required between summary line and description + "D401", # First line of docstring should be in imperative mood + "DOC201", # `return` is not documented in docstring + "DOC402", # `yield` is not documented in docstring + "DOC501", # Raised exception missing from docstring + # These can be re-enabled once we deprecate Python 3.9 + "FA100", # future-rewritable-type-annotation + "UP007", # non-pep604-annotation +] + +[tool.ruff.lint.flake8-pytest-style] +parametrize-values-type = "tuple" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = ["PLC2701", "S101"] diff --git a/scripts/build.py b/scripts/build.py new file mode 100755 index 0000000..8eba4f1 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import subprocess # noqa: S404 +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import cache +from pathlib import Path +from typing import TYPE_CHECKING, NoReturn + +from typer import Typer + +if TYPE_CHECKING: + from collections.abc import Iterable + +app = Typer() + + +@app.callback() +def callback() -> None: + """Build tools.""" + + +@app.command() +def lint(*, fix: bool = False) -> None: + """Run all linters and type checkers in order of importance - returns the first non-zero exit code or zero.""" + _run_commands_threaded( + ( + _ruff_format_command(fix=fix), + _ruff_check_command(fix=fix, unsafe_fixes=False, statistics=False), + _mypy_command(), + _pyright_command(), + _pylint_command(), + _yamllint_command(), + _flynt_command(fix=fix), + ), + ) + + +@app.command() +def lint_and_test(*, fix: bool = False) -> None: + """Run all code fixs in order of importance - returns the first non-zero exit code or zero.""" + _run_commands_threaded( + ( + _ruff_format_command(fix=fix), + _ruff_check_command(fix=fix, unsafe_fixes=False, statistics=False), + _mypy_command(), + _pyright_command(), + _pylint_command(), + _pytest_command(), + _yamllint_command(), + _flynt_command(fix=fix), + ), + ) + + +@app.command() +def ruff_format(*, fix: bool = False) -> None: + """Run Ruff linter.""" + _run(_ruff_format_command(fix=fix)) + + +def _ruff_format_command(*, fix: bool) -> str: + return f"ruff format {'' if fix else '--check '}{_python_base_paths_str()}" + + +@app.command() +def ruff_check( + *, + fix: bool = False, + unsafe_fixes: bool = False, + statistics: bool = False, +) -> None: + """Run Ruff linter.""" + _run(_ruff_check_command(fix=fix, unsafe_fixes=unsafe_fixes, statistics=statistics)) + + +def _ruff_check_command(*, fix: bool, unsafe_fixes: bool, statistics: bool) -> str: + return f"ruff check --output-format=concise {'--fix ' if fix else ''}{'--unsafe-fixes ' if unsafe_fixes else ''}{'--statistics ' if statistics else ''}{_python_base_paths_str()}" + + +@app.command() +def pytest( + *, + profile: bool = False, + coverage: bool = True, + threaded: bool = False, +) -> None: + """Run pytest unittests.""" + _run( + _pytest_command( + profile=profile, + coverage=coverage, + threaded=threaded, + ), + environment={"COVERAGE_CORE": "sysmon"}, + ) + + +def _pytest_command( + *, + profile: bool = False, + coverage: bool = True, + threaded: bool = False, +) -> str: + command = "pytest" + if profile: + command += " --profile --profile-svg" + if coverage: + command += " --cov=hier_config --cov-fail-under=70 --cov-report=term-missing" + if threaded: + command += " -n auto" + return command + + +@app.command() +def yamllint() -> None: + """Run yamllint to check YAML syntax.""" + _run(_yamllint_command()) + + +def _yamllint_command() -> str: + return f"yamllint {_repo_path().relative_to(Path.cwd())}" + + +@app.command() +def pylint() -> None: + """Run pylint linter.""" + _run(_pylint_command()) + + +def _pylint_command() -> str: + return f"pylint {_python_base_paths_str()}" + + +@app.command() +def mypy() -> None: + """Run mypy type checker.""" + _run(_mypy_command()) + + +def _mypy_command() -> str: + return f"mypy {_python_base_paths_str()}" + + +@app.command() +def pyright() -> None: + """Run pyright type checker.""" + _run(_pyright_command()) + + +def _pyright_command() -> str: + return f"pyright {_python_base_paths_str()}" + + +@app.command() +def flynt(*, fix: bool = False) -> None: + """Run flynt to enforce the use of f-strings.""" + _run(_flynt_command(fix=fix)) + + +def _flynt_command(*, fix: bool) -> str: + if fix: + return f"flynt -tc {_python_base_paths_str()}" + return f"flynt -d -tc -f {_python_base_paths_str()}" + + +@cache +def _python_base_paths_str() -> str: + return " ".join(str(p) for p in _python_base_paths()) + + +def _python_base_paths() -> Iterable[Path]: + yield from (f.relative_to(Path.cwd()) for f in _project_base_paths("*.py")) + + +def _project_base_paths(glob: str) -> Iterable[Path]: + yield from _project_paths(glob) + yield from _project_base_files(glob) + + +def _project_base_files(glob: str) -> Iterable[Path]: + yield from _repo_path().glob(glob) + + +def _project_paths(glob: str) -> Iterable[Path]: + for base_dir in ("hier_config", "tests", "scripts"): + base_path = _repo_path().joinpath(base_dir) + if not base_path.exists(): + message = f"{base_path=} does not exist" + raise FileNotFoundError(message) + + if next(base_path.glob(glob), None): + yield base_path + + +def _run_commands_threaded(commands: tuple[str, ...]) -> NoReturn: + return_codes: dict[str, int] = {} + with ThreadPoolExecutor(max_workers=len(commands)) as executor: + for future in as_completed( + executor.submit( + _run_for_thread, + command, + ) + for command in commands + ): + command, return_code, output = future.result() + if return_code: + print(output) # noqa: T201 + return_codes[command] = return_code + + error_found = False + for command, return_code in return_codes.items(): + if return_code != 0: + print(f"{command.split()[0]} -> {return_code}") # noqa: T201 + error_found = True + if error_found: + sys.exit(1) + print("No issues found") # noqa: T201 + sys.exit() + + +def _run( + command: str, + *, + check: bool = True, + environment: dict[str, str] | None = None, +) -> int: + print(f"\n======== {command} ========\n") # noqa: T201 + my_env = os.environ.copy() + if environment: + my_env.update(environment) + result = subprocess.run(command.split(), check=False, env=my_env) # noqa: S603 + if check: + sys.exit(result.returncode) + return result.returncode + + +def _run_for_thread(command: str) -> tuple[str, int, str]: + print(f"Running: {command}") # noqa: T201 + result = subprocess.run( # noqa: S603 + command.split(), + check=False, + capture_output=True, + ) + output = f"\n======== {command} ========\n{result.stdout.decode()}\n{result.stderr.decode()}" + return command, result.returncode, output + + +@cache +def _repo_path() -> Path: + return Path(__file__).parents[1] + + +def main() -> None: + app() + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1b1dfe6..0000000 --- a/setup.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[aliases] -test=pytest - -[tool:pytest] -addopts = - -vv - --cov=hier_config --cov-fail-under=77 --cov-report=term-missing - --black -; --pylint --pylint-ignore=tests -; --mypy - -;[mypy] -;python_version = 3.8 -;warn_unused_configs = True - -# Per-module options: - -;[mypy-hier_config.*] -;disallow_untyped_defs = True -; -;[mypy-tests] -;ignore_errors = True - -;[mypy-docs._mkdocs.conf] -;ignore_errors = True -; -;[mypy-docs._mkdocs.conf] -;ignore_errors = True diff --git a/tests/config_view/__init__.py b/tests/config_view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config_view/test_interface.py b/tests/config_view/test_interface.py new file mode 100644 index 0000000..70e3b0d --- /dev/null +++ b/tests/config_view/test_interface.py @@ -0,0 +1,45 @@ +from hier_config import get_hconfig, get_hconfig_view +from hier_config.models import Platform +from hier_config.platforms.hp_procurve.functions import hp_procurve_expand_range + + +def test_hp_procurve_expand_range() -> None: + assert hp_procurve_expand_range("1/1,1/2") == ("1/1", "1/2") + assert hp_procurve_expand_range("1/1-1/2") == ("1/1", "1/2") + assert hp_procurve_expand_range("1/1-1/4,1/10,1/11,1/14-1/15") == ( + "1/1", + "1/2", + "1/3", + "1/4", + "1/10", + "1/11", + "1/14", + "1/15", + ) + assert hp_procurve_expand_range("Trk1-Trk3") == ("Trk1", "Trk2", "Trk3") + assert hp_procurve_expand_range("2/A2-2/A4") == ("2/A2", "2/A3", "2/A4") + assert hp_procurve_expand_range("Trk1") == ("Trk1",) + assert hp_procurve_expand_range("1/13") == ("1/13",) + assert hp_procurve_expand_range("13") == ("13",) + assert hp_procurve_expand_range("1-4,6-8,16") == ( + "1", + "2", + "3", + "4", + "6", + "7", + "8", + "16", + ) + + +def test_bundle_name() -> None: + config = get_hconfig(Platform.CISCO_IOS) + config.add_children_deep(("interface GigabitEthernet1/1/3", "channel-group 1")) + config.add_child("interface Port-channel1") + + interface_view = get_hconfig_view(config).interface_view_by_name( + "GigabitEthernet1/1/3" + ) + assert interface_view is not None + assert interface_view.bundle_name == "Port-channel1" diff --git a/tests/conftest.py b/tests/conftest.py index 343f11c..acf5d8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,73 +1,79 @@ -from os import path +from pathlib import Path -import yaml import pytest +import yaml +from pydantic import TypeAdapter - -@pytest.fixture(scope="module") -def generated_config_junos(): - return open(f"{_fixture_dir()}/generated_config_junos.conf").read() +from hier_config.models import Platform, TagRule @pytest.fixture(scope="module") -def running_config_junos(): - return open(f"{_fixture_dir()}/running_config_junos.conf").read() +def generated_config() -> str: + return _fixture_file_read("generated_config.conf") @pytest.fixture(scope="module") -def generated_config_flat_junos(): - return open(f"{_fixture_dir()}/generated_config_flat_junos.conf").read() +def running_config() -> str: + return _fixture_file_read("running_config.conf") @pytest.fixture(scope="module") -def running_config_flat_junos(): - return open(f"{_fixture_dir()}/running_config_flat_junos.conf").read() +def remediation_config_with_safe_tags() -> str: + return _fixture_file_read("remediation_config_with_safe_tags.conf") @pytest.fixture(scope="module") -def remediation_config_flat_junos(): - return open(f"{_fixture_dir()}/remediation_config_flat_junos.conf").read() +def remediation_config_without_tags() -> str: + return _fixture_file_read("remediation_config_without_tags.conf") @pytest.fixture(scope="module") -def options_junos(): - return yaml.safe_load(open(f"{_fixture_dir()}/options_junos.yml").read()) +def platform_a() -> Platform: + return Platform.CISCO_IOS @pytest.fixture(scope="module") -def generated_config(): - return open(f"{_fixture_dir()}/generated_config.conf").read() +def platform_b() -> Platform: + return Platform.CISCO_IOS @pytest.fixture(scope="module") -def running_config(): - return open(f"{_fixture_dir()}/running_config.conf").read() +def tag_rules_ios() -> tuple[TagRule, ...]: + return TypeAdapter(tuple[TagRule, ...]).validate_python( + yaml.safe_load(_fixture_file_read("tag_rules_ios.yml")) + ) @pytest.fixture(scope="module") -def remediation_config_with_safe_tags(): - return open(f"{_fixture_dir()}/remediation_config_with_safe_tags.conf").read() +def generated_config_junos() -> str: + return _fixture_file_read("generated_config_junos.conf") @pytest.fixture(scope="module") -def remediation_config_without_tags(): - return open(f"{_fixture_dir()}/remediation_config_without_tags.conf").read() +def running_config_junos() -> str: + return _fixture_file_read("running_config_junos.conf") @pytest.fixture(scope="module") -def options_ios(): - return yaml.safe_load(open(f"{_fixture_dir()}/options_ios.yml").read()) +def generated_config_flat_junos() -> str: + return _fixture_file_read("generated_config_flat_junos.conf") @pytest.fixture(scope="module") -def tags_ios(): - return yaml.safe_load(open(f"{_fixture_dir()}/tags_ios.yml").read()) +def running_config_flat_junos() -> str: + return _fixture_file_read("running_config_flat_junos.conf") @pytest.fixture(scope="module") -def options_negate_with_undo(): - return yaml.safe_load(open(f"{_fixture_dir()}/options_negate_with_undo.yml").read()) - - -def _fixture_dir(): - return path.join(path.dirname(path.realpath(__file__)), "fixtures") +def remediation_config_flat_junos() -> str: + return _fixture_file_read("remediation_config_flat_junos.conf") + + +def _fixture_file_read(filename: str) -> str: + return str( + Path(__file__) + .resolve() + .parent.joinpath("fixtures") + .joinpath(filename) + .read_text(encoding="utf8"), + ) diff --git a/tests/fixtures/options_ios.yml b/tests/fixtures/options_ios.yml deleted file mode 100644 index b0cf907..0000000 --- a/tests/fixtures/options_ios.yml +++ /dev/null @@ -1,123 +0,0 @@ ---- -# Indicates the style of the configuration -style: ios - -negation: "no" - -syntax_style: "cisco" - -# if there is a delta, negate the parents and re-write the parents with children -sectional_overwrite: [] - -# if there is a delta, overwrite these parents instead of one of their children -sectional_overwrite_no_negate: [] - -# The default order value is 500, with a range between 1 - 999. -# Commands with smaller order values float to the top in the order of execution. -# Commands with larger order values float to the bottom in the order of execution. -# Syntax Example: -# - lineage: -# - startswith: -# - no route-map -# order: 600 -ordering: -- lineage: - - startswith: no vlan filter - order: 700 -- lineage: - - startswith: interface - - startswith: no shutdown - order: 700 - -# adds +1 indent to lines following start_expression and removes the +1 indent for lines following end_expression -indent_adjust: [] - -parent_allows_duplicate_child: [] - -sectional_exiting: -# This rule is used in the hierarchical_configuration unit test for .add_section_exiting() -- lineage: - - startswith: router bgp - - startswith: template peer-policy - exit_text: exit-peer-policy -- lineage: - - startswith: router bgp - - startswith: template peer-session - exit_text: exit-peer-session -- lineage: - - startswith: router bgp - - startswith: address-family - exit_text: exit-address-family - -# substitions against the full multi-line config text -full_text_sub: [] -#- search: 'banner\s(exec|motd)\s(\S)\n(.*\n){1,}(\2)' -# replace: '' -#- search: 'banner\s(exec|motd)\s(\S.).+\n(.*\n){1,}.*(\2)' -# replace: '' -#- search: 'banner\s(exec|motd)\s(\S.)\n(.*\n){1,}(\2)' -# replace: '' - -# substitions against each line of the config text -per_line_sub: -- search: ^Building configuration.* - replace: '' -- search: ^Current configuration.* - replace: '' -- search: ^! Last configuration change.* - replace: '' -- search: ^! NVRAM config last updated.* - replace: '' -- search: ^ntp clock-period .* - replace: '' -- search: ^version.* - replace: '' -- search: ^ logging event link-status$ - replace: '' -- search: ^ logging event subif-link-status$ - replace: '' -- search: ^\s*ipv6 unreachables disable$ - replace: '' -- search: ^end$ - replace: '' -- search: '^\s*[#!].*' - replace: '' -- search: ^ no ip address - replace: '' -- search: ^ exit-peer-policy - replace: '' -- search: ^ exit-peer-session - replace: '' -- search: ^ exit-address-family - replace: '' -- search: ^crypto key generate rsa general-keys.*$ - replace: '' - -idempotent_commands_blacklist: [] - -# These commands do not require negation, they simply overwrite themselves -# Example Syntax -# - lineage: -# - startswith: interface -# - startswith: description -idempotent_commands: -- lineage: - - startswith: vlan - - startswith: name -- lineage: - - startswith: interface - - startswith: description -- lineage: - - startswith: interface - - startswith: ip address - -# Default when expression: list of expressions -negation_default_when: [] - -# Negate substitutions: expression -> negate with -# Example Syntax: -# - lineage: -# - startswith: route-map -# - startswith: description -# use: no description -negation_negate_with: [] diff --git a/tests/fixtures/options_junos.yml b/tests/fixtures/options_junos.yml deleted file mode 100644 index 5edf7dc..0000000 --- a/tests/fixtures/options_junos.yml +++ /dev/null @@ -1,64 +0,0 @@ ---- -# Indicates the style of the configuration -style: junos - -# negation prefix -negation: "delete" - -syntax_style: "juniper" - -# if there is a delta, negate the parents and re-write the parents with children -sectional_overwrite: [] - -# if there is a delta, overwrite these parents instead of one of their children -sectional_overwrite_no_negate: [] - -# The default order value is 500, with a range between 1 - 999. -# Commands with smaller order values float to the top in the order of execution. -# Commands with larger order values float to the bottom in the order of execution. -# Syntax Example: -# - lineage: -# - startswith: -# - no route-map -# order: 600 -ordering: [] - -# adds +1 indent to lines following start_expression and removes the +1 indent for lines following end_expression -indent_adjust: [] - -parent_allows_duplicate_child: [] - -sectional_exiting: [] -# This rule is used in the hierarchical_configuration unit test for .add_section_exiting() - -# substitions against the full multi-line config text -full_text_sub: [] -#- search: 'banner\s(exec|motd)\s(\S)\n(.*\n){1,}(\2)' -# replace: '' -#- search: 'banner\s(exec|motd)\s(\S.).+\n(.*\n){1,}.*(\2)' -# replace: '' -#- search: 'banner\s(exec|motd)\s(\S.)\n(.*\n){1,}(\2)' -# replace: '' - -# substitions against each line of the config text -per_line_sub: [] - -idempotent_commands_blacklist: [] - -# These commands do not require negation, they simply overwrite themselves -# Example Syntax -# - lineage: -# - startswith: interface -# - startswith: description -idempotent_commands: [] - -# Default when expression: list of expressions -negation_default_when: [] - -# Negate substitutions: expression -> negate with -# Example Syntax: -# - lineage: -# - startswith: route-map -# - startswith: description -# use: no description -negation_negate_with: [] diff --git a/tests/fixtures/options_negate_with_undo.yml b/tests/fixtures/options_negate_with_undo.yml deleted file mode 100644 index 99589b0..0000000 --- a/tests/fixtures/options_negate_with_undo.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -# Indicates the style of the configuration -style: comware5 - -# negation prefix -negation: 'undo' - -syntax_style: "cisco" - -sectional_overwrite: [] -sectional_overwrite_no_negate: [] -ordering: [] -indent_adjust: [] -parent_allows_duplicate_child: [] -sectional_exiting: [] -full_text_sub: [] -per_line_sub: [] -idempotent_commands_blacklist: [] -idempotent_commands: [] -negation_default_when: [] -negation_negate_with: [] \ No newline at end of file diff --git a/tests/fixtures/tags_ios.yml b/tests/fixtures/tag_rules_ios.yml similarity index 75% rename from tests/fixtures/tags_ios.yml rename to tests/fixtures/tag_rules_ios.yml index 4d4b0a9..3bf4e9d 100644 --- a/tests/fixtures/tags_ios.yml +++ b/tests/fixtures/tag_rules_ios.yml @@ -1,24 +1,23 @@ ---- -- lineage: +- match_rules: - equals: - no ip http secure-server - no ip http server - vlan - no vlan - add_tags: safe -- lineage: + apply_tags: [safe] +- match_rules: - startswith: interface Vlan - startswith: - description - add_tags: safe -- lineage: + apply_tags: [safe] +- match_rules: - startswith: - ip access-list - no ip access-list - access-list - no access-list - add_tags: manual -- lineage: + apply_tags: [manual] +- match_rules: - startswith: interface Vlan - startswith: - ip address @@ -29,4 +28,4 @@ - no ip access-group - shutdown - no shutdown - add_tags: manual + apply_tags: [manual] diff --git a/tests/test_driver.py b/tests/test_driver.py new file mode 100644 index 0000000..b3796d6 --- /dev/null +++ b/tests/test_driver.py @@ -0,0 +1,21 @@ +from hier_config import get_hconfig_driver +from hier_config.models import Platform +from hier_config.platforms.arista_eos.driver import HConfigDriverAristaEOS +from hier_config.platforms.cisco_ios.driver import HConfigDriverCiscoIOS +from hier_config.platforms.cisco_nxos.driver import HConfigDriverCiscoNXOS +from hier_config.platforms.cisco_xr.driver import HConfigDriverCiscoIOSXR +from hier_config.platforms.generic.driver import HConfigDriverGeneric +from hier_config.platforms.hp_comware5.driver import HConfigDriverHPComware5 +from hier_config.platforms.hp_procurve.driver import HConfigDriverHPProcurve +from hier_config.platforms.vyos.driver import HConfigDriverVYOS + + +def test_get_hconfig_driver() -> None: + assert isinstance(get_hconfig_driver(Platform.ARISTA_EOS), HConfigDriverAristaEOS) + assert isinstance(get_hconfig_driver(Platform.CISCO_IOS), HConfigDriverCiscoIOS) + assert isinstance(get_hconfig_driver(Platform.CISCO_NXOS), HConfigDriverCiscoNXOS) + assert isinstance(get_hconfig_driver(Platform.CISCO_XR), HConfigDriverCiscoIOSXR) + assert isinstance(get_hconfig_driver(Platform.GENERIC), HConfigDriverGeneric) + assert isinstance(get_hconfig_driver(Platform.HP_PROCURVE), HConfigDriverHPProcurve) + assert isinstance(get_hconfig_driver(Platform.HP_COMWARE5), HConfigDriverHPComware5) + assert isinstance(get_hconfig_driver(Platform.VYOS), HConfigDriverVYOS) diff --git a/tests/test_driver_cisco_ios.py b/tests/test_driver_cisco_ios.py new file mode 100644 index 0000000..cfc961c --- /dev/null +++ b/tests/test_driver_cisco_ios.py @@ -0,0 +1,48 @@ +from hier_config import get_hconfig_fast_load +from hier_config.constructors import get_hconfig +from hier_config.models import Platform + + +def test_logging_console_emergencies_scenario_1() -> None: + platform = Platform.CISCO_IOS + running_config = get_hconfig_fast_load(platform, ("no logging console",)) + generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) + remediation_config = running_config.config_to_get_to(generated_config) + assert remediation_config.dump_simple() == ("logging console emergencies",) + future_config = running_config.future(remediation_config) + assert future_config.dump_simple() == ("logging console emergencies",) + rollback = future_config.config_to_get_to(running_config) + assert rollback.dump_simple() == ("no logging console",) + running_after_rollback = future_config.future(rollback) + + assert not tuple(running_config.unified_diff(running_after_rollback)) + + +def test_logging_console_emergencies_scenario_2() -> None: + platform = Platform.CISCO_IOS + running_config = get_hconfig_fast_load(platform, ("logging console",)) + generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) + remediation_config = running_config.config_to_get_to(generated_config) + assert remediation_config.dump_simple() == ("logging console emergencies",) + future_config = running_config.future(remediation_config) + assert future_config.dump_simple() == ("logging console emergencies",) + rollback = future_config.config_to_get_to(running_config) + assert rollback.dump_simple() == ("logging console",) + running_after_rollback = future_config.future(rollback) + + assert not tuple(running_config.unified_diff(running_after_rollback)) + + +def test_logging_console_emergencies_scenario_3() -> None: + platform = Platform.CISCO_IOS + running_config = get_hconfig(platform) + generated_config = get_hconfig_fast_load(platform, ("logging console emergencies",)) + remediation_config = running_config.config_to_get_to(generated_config) + assert remediation_config.dump_simple() == ("logging console emergencies",) + future_config = running_config.future(remediation_config) + assert future_config.dump_simple() == ("logging console emergencies",) + rollback = future_config.config_to_get_to(running_config) + assert rollback.dump_simple() == ("logging console debugging",) + running_after_rollback = future_config.future(rollback) + + assert not tuple(running_config.unified_diff(running_after_rollback)) diff --git a/tests/test_driver_hp_procurve.py b/tests/test_driver_hp_procurve.py new file mode 100644 index 0000000..5023242 --- /dev/null +++ b/tests/test_driver_hp_procurve.py @@ -0,0 +1,82 @@ +from hier_config import get_hconfig_fast_load +from hier_config.constructors import get_hconfig +from hier_config.models import Platform + + +def test_negate_with() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = get_hconfig(platform) + remediation_config = running_config.config_to_get_to(generated_config) + assert remediation_config.dump_simple() == ( + "aaa port-access authenticator 1/1 tx-period 30", + "aaa port-access authenticator 1/1 supplicant-timeout 30", + "no aaa port-access authenticator 1/1 client-limit", + "aaa port-access mac-based 1/1 addr-limit 1", + "aaa port-access mac-based 1/1 logoff-period 300", + "no aaa port-access 1/1 critical-auth user-role", + ) + + +def test_idempotent_for() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 3", + "aaa port-access authenticator 1/1 supplicant-timeout 3", + "aaa port-access authenticator 1/1 client-limit 4", + "aaa port-access mac-based 1/1 addr-limit 4", + "aaa port-access mac-based 1/1 logoff-period 3", + 'aaa port-access 1/1 critical-auth user-role "allowall"', + ), + ) + generated_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ), + ) + remediation_config = running_config.config_to_get_to(generated_config) + assert remediation_config.dump_simple() == ( + "aaa port-access authenticator 1/1 tx-period 4", + "aaa port-access authenticator 1/1 supplicant-timeout 4", + "aaa port-access authenticator 1/1 client-limit 5", + "aaa port-access mac-based 1/1 addr-limit 5", + "aaa port-access mac-based 1/1 logoff-period 4", + 'aaa port-access 1/1 critical-auth user-role "allownone"', + ) + + +def test_future() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig(platform) + remediation_config = get_hconfig_fast_load( + platform, + ( + "aaa port-access authenticator 3/34", + "aaa port-access authenticator 3/34 tx-period 10", + "aaa port-access authenticator 3/34 supplicant-timeout 10", + "aaa port-access authenticator 3/34 client-limit 2", + "aaa port-access mac-based 3/34", + "aaa port-access mac-based 3/34 addr-limit 2", + 'aaa port-access 3/34 critical-auth user-role "allowall"', + ), + ) + future_config = running_config.future(remediation_config) + assert not tuple(remediation_config.unified_diff(future_config)) diff --git a/tests/test_hier_config.py b/tests/test_hier_config.py index 418a694..83dcd0e 100644 --- a/tests/test_hier_config.py +++ b/tests/test_hier_config.py @@ -1,550 +1,622 @@ import tempfile -import os import types +from pathlib import Path -import pytest +from hier_config import ( + HConfigChild, + get_hconfig, + get_hconfig_driver, + get_hconfig_fast_load, + get_hconfig_from_dump, +) +from hier_config.models import Instance, MatchRule, Platform -from hier_config import HConfig, Host +def test_bool(platform_a: Platform) -> None: + config = get_hconfig(platform_a) + assert config -# pylint: ignore=too-many-public-methods -class TestHConfig: - @pytest.fixture(autouse=True) - def setup(self, options_ios): - self.os = "ios" - self.host_a = Host("example1.rtr", self.os, options_ios) - self.host_b = Host("example2.rtr", self.os, options_ios) - def test_bool(self): - config = HConfig(host=self.host_a) - assert config +def test_hash(platform_a: Platform) -> None: + config = get_hconfig_fast_load(platform_a, ("interface 1/1", " untagged vlan 5")) + assert hash(config) - def test_merge(self): - hier1 = HConfig(host=self.host_a) - hier1.add_child("interface Vlan2") - hier2 = HConfig(host=self.host_b) - hier2.add_child("interface Vlan3") - assert len(list(hier1.all_children())) == 1 - assert len(list(hier2.all_children())) == 1 +def test_merge(platform_a: Platform, platform_b: Platform) -> None: + hier1 = get_hconfig(platform_a) + hier1.add_child("interface Vlan2") + hier2 = get_hconfig(platform_b) + hier2.add_child("interface Vlan3") + + assert len(tuple(hier1.all_children())) == 1 + assert len(tuple(hier2.all_children())) == 1 + + hier1.merge(hier2) + + assert len(tuple(hier1.all_children())) == 2 + + +def test_load_from_file(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + + with tempfile.NamedTemporaryFile( + mode="r+", + delete=False, + encoding="utf8", + ) as myfile: + myfile.file.write(config) + myfile.file.flush() + myfile.close() + hier = get_hconfig(get_hconfig_driver(platform_a), Path(myfile.name)) + Path(myfile.name).unlink() + + assert len(tuple(hier.all_children())) == 2 + + +def test_load_from_config_text(platform_a: Platform) -> None: + config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + hier = get_hconfig(get_hconfig_driver(platform_a), config) + assert len(tuple(hier.all_children())) == 2 + + +def test_dump_and_load_from_dump_and_compare(platform_a: Platform) -> None: + hier_pre_dump = get_hconfig(platform_a) + b2 = hier_pre_dump.add_children_deep(("a1", "b2")) + + b2.order_weight = 400 + b2.tags_add("test") + b2.comments.add("test comment") + b2.new_in_config = True + + dump = hier_pre_dump.dump() + hier_post_dump = get_hconfig_from_dump(hier_pre_dump.driver, dump) + + assert hier_pre_dump == hier_post_dump + + +def test_add_ancestor_copy_of(platform_a: Platform) -> None: + hier1 = get_hconfig(platform_a) + interface = hier1.add_child("interface Vlan2") + interface.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) + hier1.add_ancestor_copy_of(interface) + + assert len(tuple(hier1.all_children())) == 3 + assert isinstance(hier1.all_children(), types.GeneratorType) + + +def test_depth(platform_a: Platform) -> None: + ip_address = get_hconfig(platform_a).add_children_deep( + ("interface Vlan2", "ip address 192.168.1.1 255.255.255.0"), + ) + assert ip_address.depth() == 2 + + +def test_get_child(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + hier.add_child("interface Vlan2") + child = hier.get_child(equals="interface Vlan2") + assert child is not None + assert child.text == "interface Vlan2" + + +def test_get_child_deep(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + interface1 = hier.add_child("interface Vlan1") + interface1.add_children( + ("ip address 192.168.1.1 255.255.255.0", "description asdf1"), + ) + interface2 = hier.add_child("interface Vlan2") + interface2.add_children( + ("ip address 192.168.2.1 255.255.255.0", "description asdf2"), + ) + interface3 = hier.add_child("interface Vlan3") + interface3.add_children( + ("ip address 192.168.3.1 255.255.255.0", "description asdf3"), + ) + + # search all 'interface vlan' interfaces for 'ip address' + children = tuple( + hier.get_children_deep( + ( + MatchRule(startswith="interface Vlan"), + MatchRule(startswith="ip address "), + ), + ), + ) + assert len(children) == 3 + children = tuple( + hier.get_children_deep( + ( + MatchRule(startswith="interface Vlan1"), + MatchRule(startswith="ip address "), + ), + ), + ) + assert len(children) == 1 + children = tuple( + hier.get_children_deep( + ( + MatchRule(equals="interface Vlan2"), + MatchRule(equals="ip address 192.168.2.1 255.255.255.0"), + ), + ), + ) + assert len(children) == 1 + + +def test_child_deep2() -> None: + config = get_hconfig(Platform.CISCO_IOS) + + config.add_children_deep(("a", "b")) + config.add_children_deep(("a", "b1")) + config.add_children_deep(("a", "b2")) + + assert ( + len( + tuple( + config.get_children_deep( + (MatchRule(startswith="a"), MatchRule(startswith="b")), + ), + ), + ) + == 3 + ) + + assert ( + len( + tuple( + config.get_children_deep( + (MatchRule(equals="a"), MatchRule(startswith="b2")), + ), + ), + ) + == 1 + ) - hier1.merge(hier2) - assert len(list(hier1.all_children())) == 2 +def test_get_children(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + hier.add_child("interface Vlan2") + hier.add_child("interface Vlan3") + children = tuple(hier.get_children(startswith="interface")) + assert len(children) == 2 + for child in children: + assert child.text.startswith("interface Vlan") - def test_load_from_file(self): - hier = HConfig(host=self.host_a) - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" - with tempfile.NamedTemporaryFile(mode="r+", delete=False) as myfile: - myfile.file.write(config) - myfile.file.flush() - myfile.close() - hier.load_from_file(myfile.name) - os.remove(myfile.name) +def test_move(platform_a: Platform, platform_b: Platform) -> None: + hier1 = get_hconfig(platform_a) + interface1 = hier1.add_child("interface Vlan2") + interface1.add_child("192.168.0.1/30") - assert len(list(hier.all_children())) == 2 + assert len(tuple(hier1.all_children())) == 2 - def test_load_from_config_text(self): - hier = HConfig(host=self.host_a) - config = "interface Vlan2\n ip address 1.1.1.1 255.255.255.0" + hier2 = get_hconfig(platform_b) - hier.load_from_string(config) - assert len(list(hier.all_children())) == 2 + assert not tuple(hier2.all_children()) - def test_dump_and_load_from_dump_and_compare(self): - hier_pre_dump = HConfig(host=self.host_a) - a1 = hier_pre_dump.add_child("a1") - b2 = a1.add_child("b2") + interface1.move(hier2) - b2.order_weight = 400 - b2.tags.add("test") - b2.comments.add("test comment") - b2.new_in_config = True + assert not tuple(hier1.all_children()) + assert len(tuple(hier2.all_children())) == 2 - dump = hier_pre_dump.dump() - hier_post_dump = HConfig(host=self.host_a) - hier_post_dump.load_from_dump(dump) +def test_del_child_by_text(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + hier.add_child("interface Vlan2") + hier.delete_child_by_text("interface Vlan2") - assert hier_pre_dump == hier_post_dump + assert not tuple(hier.all_children()) - def test_add_tags(self): - hier = HConfig(host=self.host_a) - tag_rules = [{"lineage": [{"equals": "interface Vlan2"}], "add_tags": "test"}] - child = hier.add_child("interface Vlan2") - hier.add_tags(tag_rules) +def test_del_child(platform_a: Platform) -> None: + hier1 = get_hconfig(platform_a) + hier1.add_child("interface Vlan2") - assert {"test"} == child.tags + assert len(tuple(hier1.all_children())) == 1 - def test_all_children_sorted_by_lineage_rules(self, tags_ios): - hier = HConfig(host=self.host_a) - svi = hier.add_child("interface Vlan2") - svi.add_child("description switch-mgmt-10.0.2.0/24") + child_to_delete = hier1.get_child(startswith="interface") + assert child_to_delete is not None + hier1.delete_child(child_to_delete) - mgmt = hier.add_child("interface FastEthernet0") - mgmt.add_child("description mgmt-192.168.0.0/24") + assert not tuple(hier1.all_children()) - assert len(list(hier.all_children())) == 4 - assert isinstance(hier.all_children(), types.GeneratorType) - assert len(list(hier.all_children_sorted_with_lineage_rules(tags_ios))) == 2 +def test_rebuild_children_dict(platform_a: Platform) -> None: + hier1 = get_hconfig(platform_a) + interface = hier1.add_child("interface Vlan2") + interface.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) + delta_a = hier1 + hier1.rebuild_children_dict() + delta_b = hier1 - assert isinstance( - hier.all_children_sorted_with_lineage_rules(tags_ios), - types.GeneratorType, - ) + assert tuple(delta_a.all_children()) == tuple(delta_b.all_children()) - def test_add_ancestor_copy_of(self): - hier1 = HConfig(host=self.host_a) - interface = hier1.add_child("interface Vlan2") - interface.add_children( - ["description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"] - ) - hier1.add_ancestor_copy_of(interface) - - assert len(list(hier1.all_children())) == 3 - assert isinstance(hier1.all_children(), types.GeneratorType) - - def test_has_children(self): - hier = HConfig(host=self.host_a) - assert not hier.has_children() - hier.add_child("interface Vlan2") - assert hier.has_children() - - def test_depth(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") - assert ip_address.depth() == 2 - - def test_get_child(self): - hier = HConfig(host=self.host_a) - hier.add_child("interface Vlan2") - child = hier.get_child("equals", "interface Vlan2") - assert child.text == "interface Vlan2" - - def test_get_child_deep(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - interface.add_child("ip address 192.168.1.1 255.255.255.0") - child = hier.get_child_deep( - [ - ("equals", "interface Vlan2"), - ("equals", "ip address 192.168.1.1 255.255.255.0"), - ] - ) - assert child is not None - def test_get_children(self): - hier = HConfig(host=self.host_a) - hier.add_child("interface Vlan2") - hier.add_child("interface Vlan3") - children = list(hier.get_children("startswith", "interface")) - assert len(children) == 2 - for child in children: - assert child.text.startswith("interface Vlan") +def test_add_children(platform_a: Platform) -> None: + interface_items1 = ( + "description switch-mgmt 192.168.1.0/24", + "ip address 192.168.1.1/24", + ) + hier1 = get_hconfig(platform_a) + interface1 = hier1.add_child("interface Vlan2") + interface1.add_children(interface_items1) - def test_move(self): - hier1 = HConfig(host=self.host_a) - interface1 = hier1.add_child("interface Vlan2") - interface1.add_child("192.168.0.1/30") + assert len(tuple(hier1.all_children())) == 3 - assert len(list(hier1.all_children())) == 2 + interface_items2 = ("description switch-mgmt 192.168.1.0/24",) + hier2 = get_hconfig(platform_a) + interface2 = hier2.add_child("interface Vlan2") + interface2.add_children(interface_items2) - hier2 = HConfig(host=self.host_b) + assert len(tuple(hier2.all_children())) == 2 - assert len(list(hier2.all_children())) == 0 - interface1.move(hier2) +def test_add_child(platform_a: Platform) -> None: + interface = get_hconfig(platform_a).add_child("interface Vlan2") + assert interface.depth() == 1 + assert interface.text == "interface Vlan2" - assert len(list(hier1.all_children())) == 0 - assert len(list(hier2.all_children())) == 2 - def test_del_child_by_text(self): - hier = HConfig(host=self.host_a) - hier.add_child("interface Vlan2") - hier.del_child_by_text("interface Vlan2") +def test_add_deep_copy_of(platform_a: Platform, platform_b: Platform) -> None: + interface1 = get_hconfig(platform_a).add_child("interface Vlan2") + interface1.add_children( + ("description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"), + ) - assert len(list(hier.all_children())) == 0 + hier2 = get_hconfig(platform_b) + hier2.add_deep_copy_of(interface1) - def test_del_child(self): - hier1 = HConfig(host=self.host_a) - hier1.add_child("interface Vlan2") + assert len(tuple(hier2.all_children())) == 3 + assert isinstance(hier2.all_children(), types.GeneratorType) - assert len(list(hier1.all_children())) == 1 - hier1.del_child(hier1.get_child("startswith", "interface")) +def test_path(platform_a: Platform) -> None: + config_aaa = get_hconfig(platform_a).add_children_deep(("a", "aa", "aaa")) + assert tuple(config_aaa.path()) == ("a", "aa", "aaa") - assert len(list(hier1.all_children())) == 0 - def test_rebuild_children_dict(self): - hier1 = HConfig(host=self.host_a) - interface = hier1.add_child("interface Vlan2") - interface.add_children( - ["description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"] - ) - delta_a = hier1 - hier1.rebuild_children_dict() - delta_b = hier1 - - assert list(delta_a.all_children()) == list(delta_b.all_children()) - - def test_add_children(self): - interface_items1 = [ - "description switch-mgmt 192.168.1.0/24", - "ip address 192.168.1.1/24", - ] - hier1 = HConfig(host=self.host_a) - interface1 = hier1.add_child("interface Vlan2") - interface1.add_children(interface_items1) - - assert len(list(hier1.all_children())) == 3 - - interface_items2 = ["description switch-mgmt 192.168.1.0/24"] - hier2 = HConfig(host=self.host_a) - interface2 = hier2.add_child("interface Vlan2") - interface2.add_children(interface_items2) - - assert len(list(hier2.all_children())) == 2 - - def test_add_child(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - assert interface.depth() == 1 - assert interface.text == "interface Vlan2" - assert not isinstance(interface, list) - - def test_add_deep_copy_of(self): - hier1 = HConfig(host=self.host_a) - interface1 = hier1.add_child("interface Vlan2") - interface1.add_children( - ["description switch-mgmt-192.168.1.0/24", "ip address 192.168.1.0/24"] - ) +def test_cisco_style_text(platform_a: Platform) -> None: + ip_address = ( + get_hconfig(platform_a) + .add_child("interface Vlan2") + .add_child("ip address 192.168.1.1 255.255.255.0") + ) + assert ip_address.cisco_style_text() == " ip address 192.168.1.1 255.255.255.0" + assert isinstance(ip_address.cisco_style_text(), str) + assert not isinstance(ip_address.cisco_style_text(), list) - hier2 = HConfig(host=self.host_b) - hier2.add_deep_copy_of(interface1) - - assert len(list(hier2.all_children())) == 3 - assert isinstance(hier2.all_children(), types.GeneratorType) - - def test_lineage(self): - """This is covered by test_path""" - pass - - def test_path(self): - hier = HConfig(host=self.host_a) - config_a = hier.add_child("a") - config_aa = config_a.add_child("aa") - config_aaa = config_aa.add_child("aaa") - assert list(config_aaa.path()) == ["a", "aa", "aaa"] - - def test_cisco_style_text(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1 255.255.255.0") - assert ip_address.cisco_style_text() == " ip address 192.168.1.1 255.255.255.0" - assert isinstance(ip_address.cisco_style_text(), str) - assert not isinstance(ip_address.cisco_style_text(), list) - - def test_all_children_sorted_untagged(self): - config = HConfig(host=self.host_a) - interface = config.add_child("interface Vlan2") - ip_address_a = interface.add_child("ip address 192.168.1.1/24") - ip_address_a.append_tags("a") - ip_address_none = interface.add_child("ip address 192.168.2.1/24") - - assert ip_address_none is list(config.all_children_sorted_untagged())[1] - assert len(list(config.all_children_sorted_untagged())) == 2 - assert ip_address_none is list(config.all_children_sorted_untagged())[1] - - def test_all_children_sorted_by_tags(self): - config = HConfig(host=self.host_a) - config_a = config.add_child("a") - config_aa = config_a.add_child("aa") - config_a.add_child("ab") - config_aaa = config_aa.add_child("aaa") - config_aab = config_aa.add_child("aab") - config_aaa.append_tags("aaa") - config_aab.append_tags("aab") - - case_1_matches = [ - c.text for c in config.all_children_sorted_by_tags({"aaa"}, set()) - ] - assert ["a", "aa", "aaa"] == case_1_matches - case_2_matches = [ - c.text for c in config.all_children_sorted_by_tags(set(), {"aab"}) - ] - assert ["a", "aa", "aaa", "ab"] == case_2_matches - case_3_matches = [ - c.text for c in config.all_children_sorted_by_tags({"aaa"}, {"aab"}) - ] - assert ["a", "aa", "aaa"] == case_3_matches - - def test_all_children_sorted(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - interface.add_child("standby 1 ip 10.15.11.1") - assert len(list(hier.all_children_sorted())) == 2 - - def test_all_children(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - interface.add_child("standby 1 ip 10.15.11.1") - assert len(list(hier.all_children())) == 2 - - def test_delete(self): - hier = HConfig(host=self.host_a) - config_a = hier.add_child("a") - config_a.delete() - assert not hier.children - - def test_set_order_weight(self): - hier = HConfig(host=self.host_a) - child = hier.add_child("no vlan filter") - hier.set_order_weight() - assert child.order_weight == 700 - - def test_add_sectional_exiting(self): - hier = HConfig(host=self.host_a) - bgp = hier.add_child("router bgp 64500") - template = bgp.add_child("template peer-policy") - hier.add_sectional_exiting() - sectional_exit = template.get_child("equals", "exit-peer-policy") - assert sectional_exit is not None - - def test_to_tag_spec(self): - pass - - def test_tags(self): - config = HConfig(host=self.host_a) - interface = config.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - assert None in interface.tags - assert None in ip_address.tags - ip_address.append_tags("a") - assert "a" in interface.tags - assert "a" in ip_address.tags - assert "b" not in interface.tags - assert "b" not in ip_address.tags - - def test_append_tags(self): - config = HConfig(host=self.host_a) - interface = config.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.append_tags("test_tag") - assert "test_tag" in config.tags - assert "test_tag" in interface.tags - assert "test_tag" in ip_address.tags - - def test_remove_tags(self): - config = HConfig(host=self.host_a) - interface = config.add_child("interface Vlan2") - ip_address = interface.add_child("ip address 192.168.1.1/24") - ip_address.append_tags("test_tag") - assert "test_tag" in config.tags - assert "test_tag" in interface.tags - assert "test_tag" in ip_address.tags - ip_address.remove_tags("test_tag") - assert "test_tag" not in config.tags - assert "test_tag" not in interface.tags - assert "test_tag" not in ip_address.tags - - def test_with_tags(self): - pass - - def test_negate(self): - hier = HConfig(host=self.host_a) - interface = hier.add_child("interface Vlan2") - interface.negate() - assert interface.text == "no interface Vlan2" - - def test_config_to_get_to(self): - running_config_hier = HConfig(host=self.host_a) - interface = running_config_hier.add_child("interface Vlan2") - interface.add_child("ip address 192.168.1.1/24") - generated_config_hier = HConfig(host=self.host_a) - generated_config_hier.add_child("interface Vlan3") - remediation_config_hier = running_config_hier.config_to_get_to( - generated_config_hier - ) - assert len(list(remediation_config_hier.all_children())) == 2 - - def test_config_to_get_to_right(self): - running_config_hier = HConfig(host=self.host_a) - running_config_hier.add_child("do not add me") - generated_config_hier = HConfig(host=self.host_a) - generated_config_hier.add_child("do not add me") - generated_config_hier.add_child("add me") - delta = HConfig(host=self.host_a) - running_config_hier._config_to_get_to_right(generated_config_hier, delta) - assert "do not add me" not in delta - assert "add me" in delta - - def test_sectional_overwrite_no_negate_check(self): - pass - - def test_sectional_overwrite_check(self): - pass - - def test_overwrite_with(self): - pass - - def test_add_shallow_copy_of(self): - base_config = HConfig(host=self.host_a) - - config_a = HConfig(host=self.host_a) - interface_a = config_a.add_child("interface Vlan2") - interface_a.append_tags({"ta", "tb"}) - interface_a.comments.add("ca") - interface_a.order_weight = 200 - - config_b = HConfig(host=self.host_b) - interface_b = config_b.add_child("interface Vlan2") - interface_b.append_tags({"tc"}) - interface_b.comments.add("cc") - interface_b.order_weight = 201 - - copied_interface = base_config.add_shallow_copy_of(interface_a, merged=True) - assert copied_interface.tags == {"ta", "tb"} - assert copied_interface.comments == {"ca"} - assert copied_interface.order_weight == 200 - assert copied_interface.instances == [ - { - "hostname": interface_a.host.hostname, - "comments": interface_a.comments, - "tags": interface_a.tags, - } - ] - - copied_interface = base_config.add_shallow_copy_of(interface_b, merged=True) - - assert copied_interface.tags == {"ta", "tb", "tc"} - assert copied_interface.comments == {"ca", "cc"} - assert copied_interface.order_weight == 201 - assert copied_interface.instances == [ - { - "hostname": interface_a.host.hostname, - "comments": interface_a.comments, - "tags": interface_a.tags, - }, - { - "hostname": interface_b.host.hostname, - "comments": interface_b.comments, - "tags": interface_b.tags, - }, - ] - - def test_line_inclusion_test(self): - config = HConfig(host=self.host_a) - interface = config.add_child("interface Vlan2") - ip_address_ab = interface.add_child("ip address 192.168.2.1/24") - ip_address_ab.append_tags(["a", "b"]) - - assert not ip_address_ab.line_inclusion_test({"a"}, {"b"}) - assert not ip_address_ab.line_inclusion_test(set(), {"a"}) - assert ip_address_ab.line_inclusion_test({"a"}, set()) - assert not ip_address_ab.line_inclusion_test(set(), set()) - - def test_lineage_test(self): - pass - - def test_future_config(self): - running_config = HConfig(host=self.host_a) - running_config.add_children_deep(["a", "aa", "aaa", "aaaa"]) - running_config.add_children_deep(["a", "ab", "aba", "abaa"]) - config = HConfig(host=self.host_a) - config.add_children_deep(["a", "ac"]) - config.add_children_deep(["a", "no ab"]) - config.add_children_deep(["a", "no az"]) - - future_config = running_config.future(config) - assert list(c.cisco_style_text() for c in future_config.all_children()) == [ - "a", - " ac", # config lines are added first - " no az", - " aa", # self lines not in config are added last - " aaa", - " aaaa", - ] - - def test_difference1(self): - rc = ["a", " a1", " a2", " a3", "b"] - step = ["a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1"] - rc_hier = HConfig(host=self.host_a) - rc_hier.load_from_string("\n".join(rc)) - step_hier = HConfig(host=self.host_a) - step_hier.load_from_string("\n".join(step)) - - difference = step_hier.difference(rc_hier) - difference_children = list( - c.cisco_style_text() for c in difference.all_children_sorted() - ) - assert len(difference_children) == 6 - assert "c" in difference - assert "d" in difference - assert "a4" in difference.get_child("equals", "a") - assert "a5" in difference.get_child("equals", "a") - assert "d1" in difference.get_child("equals", "d") - - @staticmethod - def test_difference2(options_ios): - host = Host(hostname="test_host", os="ios", hconfig_options=options_ios) - rc = ["a", " a1", " a2", " a3", "b"] - step = ["a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1"] - rc_hier = HConfig(host=host) - rc_hier.load_from_string("\n".join(rc)) - step_hier = HConfig(host=host) - step_hier.load_from_string("\n".join(step)) - - difference = step_hier.difference(rc_hier) - difference_children = list( - c.cisco_style_text() for c in difference.all_children_sorted() - ) - assert len(difference_children) == 6 - - @staticmethod - def test_difference3(options_ios): - host = Host(hostname="test_host", os="ios", hconfig_options=options_ios) - rc = ["ip access-list extended test", " 10 a", " 20 b"] - step = ["ip access-list extended test", " 10 a", " 20 b", " 30 c"] - rc_hier = HConfig(host=host) - rc_hier.load_from_string("\n".join(rc)) - step_hier = HConfig(host=host) - step_hier.load_from_string("\n".join(step)) - - difference = step_hier.difference(rc_hier) - difference_children = list( - c.cisco_style_text() for c in difference.all_children_sorted() +def test_all_children_sorted_by_tags(platform_a: Platform) -> None: + config = get_hconfig(platform_a) + config_a = config.add_child("a") + config_aa = config_a.add_child("aa") + config_a.add_child("ab") + config_aaa = config_aa.add_child("aaa") + config_aab = config_aa.add_child("aab") + config_aaa.tags_add("aaa") + config_aab.tags_add("aab") + + case_1_matches = [ + c.text + for c in config.all_children_sorted_by_tags(frozenset(("aaa",)), frozenset()) + ] + assert case_1_matches == ["a", "aa", "aaa"] + case_2_matches = [ + c.text + for c in config.all_children_sorted_by_tags(frozenset(), frozenset(("aab",))) + ] + assert case_2_matches == ["a", "aa", "aaa", "ab"] + case_3_matches = [ + c.text + for c in config.all_children_sorted_by_tags( + frozenset(("aaa",)), + frozenset(("aab",)), ) - assert difference_children == ["ip access-list extended test", " 30 c"] - - @staticmethod - def test_unified_diff(options_ios): - host = Host(hostname="test_host", os="ios", hconfig_options=options_ios) - config_a = HConfig(host=host) - config_b = HConfig(host=host) - # deep differences - config_a.add_children_deep(["a", "aa", "aaa", "aaaa"]) - config_b.add_children_deep(["a", "aa", "aab", "aaba"]) - # these children will be the same and should not appear in the diff - config_a.add_children_deep(["b", "ba", "baa"]) - config_b.add_children_deep(["b", "ba", "baa"]) - # root level differences - config_a.add_children_deep(["c", "ca"]) - config_b.add_child("d") - - diff = list(config_a.unified_diff(config_b)) - assert diff == [ - "a", - " aa", - " - aaa", - " - aaaa", - " + aab", - " + aaba", - "- c", - " - ca", - "+ d", - ] + ] + assert case_3_matches == ["a", "aa", "aaa"] + + +def test_all_children_sorted(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + interface = hier.add_child("interface Vlan2") + interface.add_child("standby 1 ip 10.15.11.1") + assert len(tuple(hier.all_children_sorted())) == 2 + + +def test_all_children(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + interface = hier.add_child("interface Vlan2") + interface.add_child("standby 1 ip 10.15.11.1") + assert len(tuple(hier.all_children())) == 2 + + +def test_delete(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + config_a = hier.add_child("a") + config_a.delete() + assert not hier.children + + +def test_set_order_weight(platform_a: Platform) -> None: + hier = get_hconfig(platform_a) + child = hier.add_child("no vlan filter") + hier.set_order_weight() + assert child.order_weight == 200 + + +def test_tags(platform_a: Platform) -> None: + interface = get_hconfig(platform_a).add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + assert not interface.tags + assert not ip_address.tags + ip_address.tags_add("a") + assert "a" in interface.tags + assert "a" in ip_address.tags + assert "b" not in interface.tags + assert "b" not in ip_address.tags + + +def test_append_tags(platform_a: Platform) -> None: + config = get_hconfig(platform_a) + interface = config.add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + ip_address.tags_add("test_tag") + assert "test_tag" in config.tags + assert "test_tag" in interface.tags + assert "test_tag" in ip_address.tags + + +def test_remove_tags(platform_a: Platform) -> None: + config = get_hconfig(platform_a) + interface = config.add_child("interface Vlan2") + ip_address = interface.add_child("ip address 192.168.1.1/24") + ip_address.tags_add("test_tag") + assert "test_tag" in config.tags + assert "test_tag" in interface.tags + assert "test_tag" in ip_address.tags + ip_address.tags_remove("test_tag") + assert "test_tag" not in config.tags + assert "test_tag" not in interface.tags + assert "test_tag" not in ip_address.tags + + +def test_negate(platform_a: Platform) -> None: + interface = get_hconfig(platform_a).add_child("interface Vlan2") + interface.negate() + assert interface.text == "no interface Vlan2" + + +def test_config_to_get_to(platform_a: Platform) -> None: + running_config_hier = get_hconfig(platform_a) + interface = running_config_hier.add_child("interface Vlan2") + interface.add_child("ip address 192.168.1.1/24") + generated_config_hier = get_hconfig(platform_a) + generated_config_hier.add_child("interface Vlan3") + remediation_config_hier = running_config_hier.config_to_get_to( + generated_config_hier, + ) + assert len(tuple(remediation_config_hier.all_children())) == 2 + + +def test_config_to_get_to2(platform_a: Platform) -> None: + running_config_hier = get_hconfig(platform_a) + running_config_hier.add_child("do not add me") + generated_config_hier = get_hconfig(platform_a) + generated_config_hier.add_child("do not add me") + generated_config_hier.add_child("add me") + delta = get_hconfig(platform_a) + running_config_hier.config_to_get_to( + generated_config_hier, + delta, + ) + assert "do not add me" not in delta + assert "add me" in delta + + +def test_add_shallow_copy_of(platform_a: Platform, platform_b: Platform) -> None: + base_config = get_hconfig(platform_a) + + interface_a = get_hconfig(platform_a).add_child("interface Vlan2") + interface_a.tags_add(frozenset(("ta", "tb"))) + interface_a.comments.add("ca") + interface_a.order_weight = 200 + + interface_b = get_hconfig(platform_b).add_child("interface Vlan2") + interface_b.tags_add(frozenset(("tc",))) + interface_b.comments.add("cc") + interface_b.order_weight = 201 + + copied_interface = base_config.add_shallow_copy_of(interface_a, merged=True) + assert copied_interface.tags == frozenset(("ta", "tb")) + assert copied_interface.comments == frozenset(("ca",)) + assert copied_interface.order_weight == 200 + assert copied_interface.instances == [ + Instance( + id=id(interface_a.root), + comments=frozenset(interface_a.comments), + tags=interface_a.tags, + ), + ] + + copied_interface = base_config.add_shallow_copy_of(interface_b, merged=True) + + assert copied_interface.tags == frozenset(("ta", "tb", "tc")) + assert copied_interface.comments == frozenset(("ca", "cc")) + assert copied_interface.order_weight == 201 + assert copied_interface.instances == [ + Instance( + id=id(interface_a.root), + comments=frozenset(interface_a.comments), + tags=interface_a.tags, + ), + Instance( + id=id(interface_b.root), + comments=frozenset(interface_b.comments), + tags=interface_b.tags, + ), + ] + + +def test_line_inclusion_test(platform_a: Platform) -> None: + ip_address_ab = get_hconfig(platform_a).add_children_deep( + ("interface Vlan2", "ip address 192.168.2.1/24"), + ) + ip_address_ab.tags_add(frozenset(("a", "b"))) + + assert not ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset(("b",))) + assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset(("a",))) + assert ip_address_ab.line_inclusion_test(frozenset(("a",)), frozenset()) + assert not ip_address_ab.line_inclusion_test(frozenset(), frozenset()) + + +def test_future_config(platform_a: Platform) -> None: + running_config = get_hconfig(platform_a) + running_config.add_children_deep(("a", "aa", "aaa", "aaaa")) + running_config.add_children_deep(("a", "ab", "aba", "abaa")) + config = get_hconfig(platform_a) + config.add_children_deep(("a", "ac")) + config.add_children_deep(("a", "no ab")) + config.add_children_deep(("a", "no az")) + + future_config = running_config.future(config) + assert tuple(c.cisco_style_text() for c in future_config.all_children()) == ( + "a", + " ac", # config lines are added first + " no az", + " aa", # self lines not in config are added last + " aaa", + " aaaa", + ) + + +def test_difference1(platform_a: Platform) -> None: + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = get_hconfig(get_hconfig_driver(platform_a), "\n".join(rc)) + + difference = get_hconfig( + get_hconfig_driver(platform_a), "\n".join(step) + ).difference(rc_hier) + difference_children = tuple( + c.cisco_style_text() for c in difference.all_children_sorted() + ) + + assert len(difference_children) == 6 + assert "c" in difference + assert "d" in difference + difference_a = difference.get_child(equals="a") + assert isinstance(difference_a, HConfigChild) + assert "a4" in difference_a + assert "a5" in difference_a + difference_d = difference.get_child(equals="d") + assert isinstance(difference_d, HConfigChild) + assert "d1" in difference_d + + +def test_difference2() -> None: + platform = Platform.CISCO_IOS + rc = ("a", " a1", " a2", " a3", "b") + step = ("a", " a1", " a2", " a3", " a4", " a5", "b", "c", "d", " d1") + rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.cisco_style_text() + for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert len(difference_children) == 6 + + +def test_difference3() -> None: + platform = Platform.CISCO_IOS + rc = ("ip access-list extended test", " 10 a", " 20 b") + step = ("ip access-list extended test", " 10 a", " 20 b", " 30 c") + rc_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(rc)) + step_hier = get_hconfig(get_hconfig_driver(platform), "\n".join(step)) + + difference_children = tuple( + c.cisco_style_text() + for c in step_hier.difference(rc_hier).all_children_sorted() + ) + assert difference_children == ("ip access-list extended test", " 30 c") + + +def test_unified_diff() -> None: + platform = Platform.CISCO_IOS + + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + # deep differences + config_a.add_children_deep(("a", "aa", "aaa", "aaaa")) + config_b.add_children_deep(("a", "aa", "aab", "aaba")) + # these children will be the same and should not appear in the diff + config_a.add_children_deep(("b", "ba", "baa")) + config_b.add_children_deep(("b", "ba", "baa")) + # root level differences + config_a.add_children_deep(("c", "ca")) + config_b.add_child("d") + + diff = tuple(config_a.unified_diff(config_b)) + assert diff == ( + "a", + " aa", + " - aaa", + " - aaaa", + " + aab", + " + aaba", + "- c", + " - ca", + "+ d", + ) + + +def test_idempotent_commands() -> None: + platform = Platform.HP_PROCURVE + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "untagged vlan 1")) + config_b.add_children_deep((interface_name, "untagged vlan 2")) + interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="untagged vlan 2") + assert len(interface.children) == 1 + + +def test_idempotent_commands2() -> None: + platform = Platform.CISCO_IOS + config_a = get_hconfig(platform) + config_b = get_hconfig(platform) + interface_name = "interface 1/1" + config_a.add_children_deep((interface_name, "authentication host-mode multi-auth")) + config_b.add_children_deep( + (interface_name, "authentication host-mode multi-domain"), + ) + interface = config_a.config_to_get_to(config_b).get_child(equals=interface_name) + assert interface is not None + assert interface.get_child(equals="authentication host-mode multi-domain") + assert len(interface.children) == 1 + + +def test_future_config_no_command_in_source() -> None: + platform = Platform.HP_PROCURVE + running_config = get_hconfig(platform) + generated_config = get_hconfig(platform) + generated_config.add_child("no service dhcp") + + remediation_config = running_config.config_to_get_to(generated_config) + future_config = running_config.future(remediation_config) + assert len(future_config.children) == 1 + assert future_config.get_child(equals="no service dhcp") + assert not tuple(future_config.unified_diff(generated_config)) + rollback_config = future_config.config_to_get_to(running_config) + assert len(rollback_config.children) == 1 + assert rollback_config.get_child(equals="service dhcp") + calculated_running_config = future_config.future(rollback_config) + assert not calculated_running_config.children + assert not tuple(calculated_running_config.unified_diff(running_config)) diff --git a/tests/test_hier_options.py b/tests/test_hier_options.py deleted file mode 100644 index 6744d08..0000000 --- a/tests/test_hier_options.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from hier_config.options import options_for, base_options - - -class TestHConfigOptions: - @pytest.fixture(autouse=True) - def setup(self, options_ios, options_junos): - self.ios_options = options_ios - self.junos_options = options_junos - - def test_options(self): - assert self.ios_options == options_for("ios") - assert self.junos_options == options_for("junos") - assert {**base_options, **{"style": "example"}} == options_for("example") diff --git a/tests/test_host.py b/tests/test_host.py deleted file mode 100644 index 9f678be..0000000 --- a/tests/test_host.py +++ /dev/null @@ -1,66 +0,0 @@ -import pytest - -from hier_config.host import Host - - -class TestHost: - @pytest.fixture(autouse=True) - def setup(self, options_ios): - self.host = Host("example.rtr", "ios", options_ios) - self.host_bltn_opts = Host("example.rtr", "ios") - - def test_load_config_from(self, running_config, generated_config): - self.host.load_running_config(running_config) - self.host.load_generated_config(generated_config) - self.host_bltn_opts.load_running_config(running_config) - self.host_bltn_opts.load_generated_config(generated_config) - - assert len(self.host.generated_config) > 0 - assert len(self.host.running_config) > 0 - assert len(self.host_bltn_opts.running_config) > 0 - assert len(self.host_bltn_opts.generated_config) > 0 - - def test_load_remediation(self, running_config, generated_config): - self.host.load_running_config(running_config) - self.host.load_generated_config(generated_config) - self.host.remediation_config() - self.host_bltn_opts.load_running_config(running_config) - self.host_bltn_opts.load_generated_config(generated_config) - self.host_bltn_opts.remediation_config() - - assert len(self.host.remediation_config().children) > 0 - assert len(self.host_bltn_opts.remediation_config().children) > 0 - - def test_load_rollback(self, running_config, generated_config): - self.host.load_running_config(running_config) - self.host.load_generated_config(generated_config) - self.host.rollback_config() - self.host_bltn_opts.load_running_config(running_config) - self.host_bltn_opts.load_generated_config(generated_config) - self.host_bltn_opts.rollback_config() - - assert len(self.host.rollback_config().children) > 0 - assert len(self.host_bltn_opts.rollback_config().children) > 0 - - def test_load_tags(self, tags_ios): - self.host.load_tags(tags_ios) - assert len(self.host.hconfig_tags) > 0 - - def test_filter_remediation( - self, - running_config, - generated_config, - tags_ios, - remediation_config_with_safe_tags, - remediation_config_without_tags, - ): - self.host.load_running_config(running_config) - self.host.load_generated_config(generated_config) - self.host.load_tags(tags_ios) - - rem1 = self.host.remediation_config_filtered_text(set(), set()) - rem2 = self.host.remediation_config_filtered_text({"safe"}, set()) - - assert rem1 != rem2 - assert rem1 == remediation_config_without_tags - assert rem2 == remediation_config_with_safe_tags diff --git a/tests/test_juniper_syntax.py b/tests/test_juniper_syntax.py index 7977998..bc2ccb1 100644 --- a/tests/test_juniper_syntax.py +++ b/tests/test_juniper_syntax.py @@ -1,44 +1,49 @@ -import pytest - -from hier_config.host import Host - - -class TestJuniperSyntax: - @pytest.fixture(autouse=True) - def setUpClass( - self, - options_junos, - running_config_junos, - running_config_flat_junos, - generated_config_junos, - generated_config_flat_junos, - remediation_config_flat_junos, - ): - self.os = "junos" - self.host = Host("example1.rtr", self.os, options_junos) - self.running_config_str = "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2" - self.generated_config_str = "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - self.remediation_str = "delete vlans switch_mgmt_10.0.2.0/24 vlan-id 2\nset vlans switch_mgmt_10.0.3.0/24 vlan-id 3" - self.running_config_junos = running_config_junos - self.running_config_flat_junos = running_config_flat_junos - self.generated_config_junos = generated_config_junos - self.generated_config_flat_junos = generated_config_flat_junos - self.remediation_config_flat_junos = remediation_config_flat_junos - - def test_junos_basic_remediation(self): - self.host.load_running_config(self.running_config_str) - self.host.load_generated_config(self.generated_config_str) - self.host.remediation_config() - assert self.remediation_str == str(self.host.remediation_config()) - - def test_junos_convert_to_set(self): - self.host.load_running_config(self.running_config_junos) - self.host.load_generated_config(self.generated_config_junos) - assert self.remediation_config_flat_junos == str(self.host.remediation_config()) - - def test_flat_junos_remediation(self): - self.host.load_running_config(self.running_config_flat_junos) - self.host.load_generated_config(self.generated_config_flat_junos) - remediation_list = self.remediation_config_flat_junos.splitlines() - for line in str(self.host.remediation_config()).splitlines(): - assert line in remediation_list +from hier_config import WorkflowRemediation, get_hconfig, get_hconfig_fast_load +from hier_config.models import Platform + + +def test_junos_basic_remediation() -> None: + platform = Platform.JUNIPER_JUNOS + running_config_str = "set vlans switch_mgmt_10.0.2.0/24 vlan-id 2" + generated_config_str = "set vlans switch_mgmt_10.0.3.0/24 vlan-id 3" + remediation_str = "delete vlans switch_mgmt_10.0.2.0/24 vlan-id 2\nset vlans switch_mgmt_10.0.3.0/24 vlan-id 3" + + workflow_remediation = WorkflowRemediation( + get_hconfig_fast_load(platform, running_config_str), + get_hconfig_fast_load(platform, generated_config_str), + ) + + assert workflow_remediation.remediation_config_filtered_text() == remediation_str + + +def test_junos_convert_to_set( + running_config_junos: str, + generated_config_junos: str, + remediation_config_flat_junos: str, +) -> None: + platform = Platform.JUNIPER_JUNOS + workflow_remediation = WorkflowRemediation( + get_hconfig(platform, running_config_junos), + get_hconfig(platform, generated_config_junos), + ) + + assert ( + workflow_remediation.remediation_config_filtered_text() + == remediation_config_flat_junos + ) + + +def test_flat_junos_remediation( + running_config_flat_junos: str, + generated_config_flat_junos: str, + remediation_config_flat_junos: str, +) -> None: + platform = Platform.JUNIPER_JUNOS + workflow_remediation = WorkflowRemediation( + get_hconfig_fast_load(platform, running_config_flat_junos), + get_hconfig_fast_load(platform, generated_config_flat_junos), + ) + + remediation_list = remediation_config_flat_junos.splitlines() + for line in str(workflow_remediation.remediation_config).splitlines(): + assert line in remediation_list diff --git a/tests/test_negate_with_undo.py b/tests/test_negate_with_undo.py index c228631..3ae4d92 100644 --- a/tests/test_negate_with_undo.py +++ b/tests/test_negate_with_undo.py @@ -1,19 +1,18 @@ -import pytest +from hier_config import WorkflowRemediation, get_hconfig_fast_load +from hier_config.models import Platform -from hier_config.host import Host - -class TestNegateWithUndo: - @pytest.fixture(autouse=True) - def setUpClass(self, options_negate_with_undo): - self.os = "comware5" - self.running_config = "test_for_undo\nundo test_for_redo" - self.generated_config = "undo test_for_undo\ntest_for_redo" - self.remediation = "undo test_for_undo\ntest_for_redo" - self.host = Host("example1.rtr", self.os, options_negate_with_undo) - - def test_merge(self): - self.host.load_running_config(self.running_config) - self.host.load_generated_config(self.generated_config) - self.host.remediation_config() - assert self.remediation == str(self.host.remediation_config()) +def test_merge_with_undo() -> None: + platform = Platform.HP_COMWARE5 + running_config = get_hconfig_fast_load( + platform, "test_for_undo\nundo test_for_redo" + ) + generated_config = get_hconfig_fast_load( + platform, "undo test_for_undo\ntest_for_redo" + ) + expected_remediation_config = get_hconfig_fast_load( + platform, "undo test_for_undo\ntest_for_redo" + ) + workflow_remediation = WorkflowRemediation(running_config, generated_config) + remediation_config = workflow_remediation.remediation_config + assert remediation_config == expected_remediation_config diff --git a/tests/test_text_match.py b/tests/test_text_match.py deleted file mode 100644 index d3c7818..0000000 --- a/tests/test_text_match.py +++ /dev/null @@ -1,45 +0,0 @@ -from hier_config import text_match - - -text = " ip address 192.168.100.1/24" -expression1 = text -expression2 = " ip address" -expression3 = "ip access-list" -expression4 = "/30" - - -def test_equals(): - assert text_match.equals(text, expression1) - assert not text_match.equals(text, expression2) - - -def test_startswith(): - assert text_match.startswith(text, expression2) - assert not text_match.startswith(text, expression3) - - -def test_endswith(): - assert text_match.endswith(text, expression1) - assert not text_match.endswith(text, expression4) - - -def test_contains(): - assert text_match.contains(text, expression2) - assert not text_match.contains(text, expression3) - - -def test_re_search(): - assert text_match.re_search(text, expression2) - assert not text_match.re_search(text, expression3) - - -def test_anything(): - assert text_match.anything(text, expression1) - assert text_match.anything(text, expression2) - - -def test_nothing(): - assert not text_match.nothing(text, expression1) - assert not text_match.nothing(text, expression2) - assert not text_match.nothing(text, expression3) - assert not text_match.nothing(text, expression4) diff --git a/tests/test_various.py b/tests/test_various.py index f56ac4d..3c0257b 100644 --- a/tests/test_various.py +++ b/tests/test_various.py @@ -1,53 +1,27 @@ -from hier_config import HConfig, Host +from hier_config import get_hconfig, get_hconfig_driver +from hier_config.models import Platform def test_issue104() -> None: running_config_raw = ( - "tacacs-server deadtime 3\n" "tacacs-server host 192.168.1.99 key 7 Test12345\n" + "tacacs-server deadtime 3\ntacacs-server host 192.168.1.99 key 7 Test12345\n" ) generated_config_raw = ( "tacacs-server host 192.168.1.98 key 0 Test135 timeout 3\n" "tacacs-server host 192.168.100.98 key 0 test135 timeout 3\n" ) - host = Host(hostname="test", os="nxos") - running_config = HConfig(host=host) - running_config.load_from_string(running_config_raw) - generated_config = HConfig(host=host) - generated_config.load_from_string(generated_config_raw) - rem = running_config.config_to_get_to(generated_config) + platform = Platform.CISCO_NXOS + running_config = get_hconfig(get_hconfig_driver(platform), running_config_raw) + generated_config = get_hconfig(get_hconfig_driver(platform), generated_config_raw) + remediation_config = running_config.config_to_get_to(generated_config) expected_rem_lines = { "no tacacs-server deadtime 3", "no tacacs-server host 192.168.1.99 key 7 Test12345", "tacacs-server host 192.168.1.98 key 0 Test135 timeout 3", "tacacs-server host 192.168.100.98 key 0 test135 timeout 3", } - rem_lines = {line.cisco_style_text() for line in rem.all_children()} - assert expected_rem_lines == rem_lines - - -def test_issue_113() -> None: - running_config_raw = ( - "interface Ethernet1/1\n" - " description test\n" - " ip address 192.0.2.1 255.255.255.0\n" - " switchport\n" - ) - generated_config_raw = ( - "interface Ethernet1/1\n" - " ip address 192.0.2.1 255.255.255.0\n" - " switchport\n" - ) - - host = Host(hostname="test", os="ios") - running_config = HConfig(host=host) - running_config.load_from_string(running_config_raw) - generated_config = HConfig(host=host) - generated_config.load_from_string(generated_config_raw) - rem = running_config.config_to_get_to(generated_config) - expected_rem_lines = { - "interface Ethernet1/1", - " no description test", + remediation_lines = { + line.cisco_style_text() for line in remediation_config.all_children() } - rem_lines = {line.cisco_style_text() for line in rem.all_children()} - assert expected_rem_lines == rem_lines + assert expected_rem_lines == remediation_lines diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..14a8711 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,75 @@ +import pytest + +from hier_config import ( + WorkflowRemediation, + get_hconfig, +) +from hier_config.models import Platform, TagRule + + +@pytest.fixture(name="wfr") +def workflow_remediation( + running_config: str, generated_config: str +) -> WorkflowRemediation: + return WorkflowRemediation( + running_config=get_hconfig(Platform.CISCO_IOS, running_config), + generated_config=get_hconfig(Platform.CISCO_IOS, generated_config), + ) + + +def test_config_lengths(wfr: WorkflowRemediation) -> None: + assert wfr.running_config.children + assert wfr.generated_config.children + assert wfr.remediation_config.children + assert wfr.rollback_config.children + + +def test_apply_tags( + wfr: WorkflowRemediation, tag_rules_ios: tuple[TagRule, ...] +) -> None: + wfr.apply_remediation_tag_rules(tag_rules_ios) + assert len(wfr.remediation_config.tags) > 0 + + +def test_remediation_config_filtered_text( + wfr: WorkflowRemediation, + tag_rules_ios: tuple[TagRule, ...], + remediation_config_with_safe_tags: str, + remediation_config_without_tags: str, +) -> None: + wfr.apply_remediation_tag_rules(tag_rules_ios) + + rem1 = wfr.remediation_config_filtered_text(set(), set()) + rem2 = wfr.remediation_config_filtered_text({"safe"}, set()) + + assert rem1 != rem2 + assert rem1 == remediation_config_without_tags + assert rem2 == remediation_config_with_safe_tags + + +def test_remediation_config_driver_mismatch() -> None: + # Test to ensure ValueError is raised for mismatched drivers + running_config = get_hconfig(Platform.CISCO_IOS, "dummy_config") + generated_config = get_hconfig(Platform.JUNIPER_JUNOS, "dummy_config") + + with pytest.raises( + ValueError, match="The running and generated configs must use the same driver." + ): + WorkflowRemediation(running_config, generated_config) + + +def test_rollback_config_exists(wfr: WorkflowRemediation) -> None: + # Check if rollback config is generated and accessible + rollback_config = wfr.rollback_config + assert rollback_config is not None + assert len(rollback_config.children) > 0 # Ensure rollback config has content + + +def test_rollback_config_reverts_changes(wfr: WorkflowRemediation) -> None: + # Test if rollback config correctly represents changes needed to revert generated to running + rollback_config = wfr.rollback_config + rollback_text = "\n".join( + line.cisco_style_text() for line in rollback_config.all_children_sorted() + ) + expected_text = "no vlan 4\nno interface Vlan4\nvlan 3\n name switch_mgmt_10.0.4.0/24\ninterface Vlan2\n no mtu 9000\n no ip access-group TEST in\n shutdown\ninterface Vlan3\n description switch_mgmt_10.0.4.0/24\n ip address 10.0.4.1 255.255.0.0" + assert rollback_text == expected_text