diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 17d055a1d..d5273c574 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -12,10 +12,10 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" + - "3.14" steps: - uses: actions/checkout@v4 diff --git a/Justfile b/Justfile new file mode 100644 index 000000000..e3add3b5b --- /dev/null +++ b/Justfile @@ -0,0 +1,24 @@ +pkg_folder := "tenable" + + +[parallel] +test-parallel: (test-py "3.14") (test-py "3.12") (test-py "3.13") (test-py "3.14") + +test: (test-py "3.11") (test-py "3.12") (test-py "3.13") (test-py "3.14") + +docs: + sphinx-build -M clean docs docs/_build + sphinx-build -M html docs docs/_build + +test-py version: (lint version) (unit-tests version) #audit + +lint version: + uv run --python {{version}} --isolated --group dev mypy {{pkg_folder}} + uv run --python {{version}} --isolated --group dev ty check {{pkg_folder}} + uv run --python {{version}} --isolated --group dev ruff check {{pkg_folder}} + +unit-tests version: + uv run --python {{version}} --isolated --group dev pytest -q --cov-fail-under 80 + +audit: + uv audit --no-group test --no-group dev --no-group docs diff --git a/codebook.toml b/codebook.toml new file mode 100644 index 000000000..1f6815574 --- /dev/null +++ b/codebook.toml @@ -0,0 +1,68 @@ +words = [ + "acls", + "autoclass", + "autoexception", + "badgatewayerror", + "badrequesterror", + "boxify", + "bymonthday", + "byweekday", + "cidr", + "conv", + "devportal", + "expectationfailederror", + "forbiddenerror", + "fulltext", + "gatewaytimeouterror", + "httpbin", + "invalidmethoderror", + "ips", + "ipv", + "lce", + "lengthrequirederror", + "lobj", + "maptable", + "mdm", + "methodnotimplimentederror", + "misdirectrequesterror", + "netbios", + "networkauthrequirederror", + "nolongerexistserror", + "notacceptableerror", + "notextendederror", + "notfounderror", + "opsys", + "otype", + "pathing", + "payloadtoolargeerror", + "pprint", + "preconditionfailederror", + "preconditionrequirederror", + "proxyauthenticationerror", + "rangenotsatisfiableerror", + "recasted", + "requestconflicterror", + "requestheaderfieldstoolargeerror", + "requesttimeouterror", + "restfly", + "rkeys", + "rrules", + "rval", + "servererror", + "serviceunavailableerror", + "softcheck", + "softchecks", + "teapotresponseerror", + "timedelta", + "tio", + "tooearlyerror", + "toomanyrequests", + "unauthorizederror", + "unavailableforlegalreasonserror", + "unexpectedvalue", + "unsupportedmediatypeerror", + "upgraderequirederror", + "uritoolongerror", + "urlparse", + "utcnow", +] diff --git a/examples/io/agent_export_csv_writer/agentexport.py b/examples/io/agent_export_csv_writer/agentexport.py index 24aa32d86..8f2b33963 100644 --- a/examples/io/agent_export_csv_writer/agentexport.py +++ b/examples/io/agent_export_csv_writer/agentexport.py @@ -1,7 +1,8 @@ #!/usr/bin/env python from tenable.io import TenableIO from csv import DictWriter -import click, logging +import click +import logging # Function to format agent groups into a single string def agent_groups(groups): @@ -56,7 +57,7 @@ def cli(output, access_key, secret_key, health, debug): else: # Log that all agents are being exported since 'health' is None or empty - logging.info(f'Exporting all agents') + logging.info('Exporting all agents') # Define the fields/columns for the CSV fields = [ diff --git a/examples/io/asset_export_csv_writer/csvexport.py b/examples/io/asset_export_csv_writer/csvexport.py index edc2efdb0..14047cfda 100755 --- a/examples/io/asset_export_csv_writer/csvexport.py +++ b/examples/io/asset_export_csv_writer/csvexport.py @@ -1,7 +1,9 @@ #!/usr/bin/env python from tenable.io import TenableIO from csv import DictWriter -import collections, click, logging +import collections +import click +import logging def flatten(d, parent_key='', sep='.'): diff --git a/examples/io/download_scans/download_scans.py b/examples/io/download_scans/download_scans.py index ad3c93670..cb54345d7 100755 --- a/examples/io/download_scans/download_scans.py +++ b/examples/io/download_scans/download_scans.py @@ -1,6 +1,8 @@ #!/usr/bin/env python from tenable.io import TenableIO -import os, click, arrow +import os +import click +import arrow @click.command() @click.option('--tio-access-key', 'access_key', envvar='TIO_ACCESS_KEY', diff --git a/examples/io/vuln_export_csv_writer/csvexport.py b/examples/io/vuln_export_csv_writer/csvexport.py index c4f268a49..a22827352 100755 --- a/examples/io/vuln_export_csv_writer/csvexport.py +++ b/examples/io/vuln_export_csv_writer/csvexport.py @@ -1,7 +1,9 @@ #!/usr/bin/env python from tenable.io import TenableIO from csv import DictWriter -import collections, click, logging +import collections +import click +import logging def flatten(d, parent_key='', sep='.'): diff --git a/examples/io/workbench_csv_download/csvdownload.py b/examples/io/workbench_csv_download/csvdownload.py index d25aaf60d..ca55c2d66 100755 --- a/examples/io/workbench_csv_download/csvdownload.py +++ b/examples/io/workbench_csv_download/csvdownload.py @@ -22,7 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' -import argparse, os +import argparse +import os from tenable.io import TenableIO def filterset(s): diff --git a/examples/reports/scan_speeds/speed_report.py b/examples/reports/scan_speeds/speed_report.py index c03137aa2..f817ea398 100755 --- a/examples/reports/scan_speeds/speed_report.py +++ b/examples/reports/scan_speeds/speed_report.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from tenable.reports import NessusReportv2 -import click, re +import click +import re @click.command() @click.argument('report', type=click.File('r')) diff --git a/pyproject.toml b/pyproject.toml index bca19b2d1..655c06213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] -requires-python = ">=3.10" +requires-python = ">=3.11" dynamic = ["version"] readme = "README.rst" name = "pyTenable" @@ -26,6 +26,8 @@ keywords = [ "tenable container security", "tenable.ot", "tenable ot security", + "tenable one", + "tenable cloud", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -41,20 +43,20 @@ classifiers = [ ] dependencies = [ - "requests>=2.26", + "requests>=2.33", "python-dateutil>=2.6", "semver>=2.10.0", - "restfly>=1.5.1", "marshmallow>=3.8", "python-box>=4.0", "defusedxml>=0.5.0", - "urllib3>=1.26.18", + "urllib3>=1.26.20", "typing-extensions>=4.0.1", "requests-toolbelt>=1.0.0", "gql>=4.0.0", "graphql-core>=3.2.6", "pydantic>=2.5.3", "pydantic-extra-types>=2.3.0", + "arrow>=1.4.0", ] @@ -116,7 +118,7 @@ docstring-code-line-length = "dynamic" [tool.pytest.ini_options] -addopts = "--cov-report term-missing --cov=tenable" +addopts = "--cov-report term-missing:skip-covered --cov=tenable" testpaths = ['tests'] filterwarnings = [ "ignore::DeprecationWarning:tenable.*", @@ -135,15 +137,13 @@ dev = [ "pytest-cov>=4.1.0", "pytest-datafiles>=3.0.0", "pytest-vcr>=1.0.2", - "pytest>=7.4.4,<9", + "pytest>=8,<9", "responses>=0.23.3", "ruff>=0.6.4", - # URLLib3 version 2.x doesn't play well with the version of pytest-vcr that supports - # Python 3.9 and lower. This can be removed once 3.9 goes EOS or when we stop using - # VCR for the test suite. - "urllib3==1.26.20", "rich>=13.8.1", + "urllib3>=1.26.18,<2", # Required until VCR is no longer needed. "ptpython>=3.0.29", "typer>=0.20.0", "mypy>=1.18.2", + "rust-just>=1.51.0", ] diff --git a/tenable/apa/findings/api.py b/tenable/apa/findings/api.py index 699415886..348f7cb08 100644 --- a/tenable/apa/findings/api.py +++ b/tenable/apa/findings/api.py @@ -13,7 +13,7 @@ from copy import copy from typing import Dict, Optional, Union -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.apa.findings.schema import FindingsPageSchema, AttackTechniquesSearchResponseSchema from tenable.base.endpoint import APIEndpoint diff --git a/tenable/apa/vectors/api.py b/tenable/apa/vectors/api.py index 738d11561..d2f6dbc88 100644 --- a/tenable/apa/vectors/api.py +++ b/tenable/apa/vectors/api.py @@ -13,7 +13,7 @@ from copy import copy from typing import Dict, Optional, Union -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.apa.vectors.schema import VectorsPageSchema from tenable.base.endpoint import APIEndpoint diff --git a/tenable/asm/inventory.py b/tenable/asm/inventory.py index 8ce3add1d..09ab2327b 100644 --- a/tenable/asm/inventory.py +++ b/tenable/asm/inventory.py @@ -16,7 +16,7 @@ from copy import copy from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.base.endpoint import APIEndpoint diff --git a/tenable/base/_errors.py b/tenable/base/_errors.py new file mode 100644 index 000000000..d464b9db2 --- /dev/null +++ b/tenable/base/_errors.py @@ -0,0 +1,707 @@ + +""" +Errors +====== + +.. autoexception:: RestflyException +.. autoexception:: UnexpectedValueError +.. autoexception:: RequiredParameterError +.. autoexception:: APIError +.. autoexception:: BadRequestError +.. autoexception:: UnauthorizedError +.. autoexception:: ForbiddenError +.. autoexception:: NotFoundError +.. autoexception:: InvalidMethodError +.. autoexception:: NotAcceptableError +.. autoexception:: ProxyAuthenticationError +.. autoexception:: RequestTimeoutError +.. autoexception:: RequestConflictError +.. autoexception:: NoLongerExistsError +.. autoexception:: LengthRequiredError +.. autoexception:: PreconditionFailedError +.. autoexception:: PayloadTooLargeError +.. autoexception:: URITooLongError +.. autoexception:: UnsupportedMediaTypeError +.. autoexception:: RangeNotSatisfiableError +.. autoexception:: ExpectationFailedError +.. autoexception:: TeapotResponseError +.. autoexception:: MisdirectRequestError +.. autoexception:: InvalidContentError +.. autoexception:: TooEarlyError +.. autoexception:: UpgradeRequiredError +.. autoexception:: PreconditionRequiredError +.. autoexception:: TooManyRequestsError +.. autoexception:: RequestHeaderFieldsTooLargeError +.. autoexception:: UnavailableForLegalReasonsError +.. autoexception:: ServerError +.. autoexception:: MethodNotImplementedError +.. autoexception:: BadGatewayError +.. autoexception:: ServiceUnavailableError +.. autoexception:: GatewayTimeoutError +.. autoexception:: NotExtendedError +.. autoexception:: NetworkAuthenticationRequiredError +""" + +import logging + + +def api_error_func(resp, **kwargs): # noqa: PLW0613 + """ + Default message function for APIErrors + + Args: + resp (request.Response): + The HTTP response that caused the error to be thrown. + **kwargs (dict): + The keyword argument dictionary from the APIError + + Returns: + :obj:`str`: + The string message for the error. + """ + return ( + f'[{str(resp.status_code)}: {str(resp.request.method)}] ' + f'{str(resp.request.url)} body={str(resp.content)}' + ) + + +def base_msg_func(msg, **kwargs): # noqa: PLW0613 + """ + Default function used for RestflyException + + Args: + msg (str): + The message string to be returned + **kwargs (dict): + The keyword argument dictionary from the RestflyException + + Returns: + :obj:`str`: + The string message + """ + return str(msg) + + +class RestflyException(Exception): + """ + Base exception class that sets up logging and handles some basic + scaffolding for all other exception classes. This exception should never + be directly seen. + """ + + def __init__(self, msg, **kwargs): + self._log = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}') + self.msg = kwargs.get('func', base_msg_func)(msg, **kwargs) + self._log.error(self.__str__()) + super().__init__() + + def __str__(self): + return str(self.msg) + + def __repr__(self): + return repr(self.__str__()) + + +class UnexpectedValueError(RestflyException): + """ + An unexpected value error is thrown whenever the value specified for a + parameter is outside the bounds of what is expected. For example, if the + parameter **a** is expected to have a value of 1, 2, or 3, and it is + instead passed a value of 0, then it is an unexpected value, and this + Exception should be thrown by the package. + """ + + +class RequiredParameterError(RestflyException): + """ + A Required Parameter error is thrown whenever the value specified for a + parameter is required to have a value other than `None`. + """ + + +class ConnectionError(RestflyException): # noqa: PLW0622 + """ + A connection-error is thrown only for products like Tenable.sc or Nessus, + where the application may be installed anywhere. This error is thrown if + we are unable to complete the initial connection or gather the basic + information about the application that is necessary. + """ + + +class AuthenticationWarning(Warning): # noqa: PLW0622 + """ + An authentication warning is thrown when an unauthenticated API session is + initiated. + """ + + +class PackageMissingError(RestflyException): + """ + In situations where an optional library is needed, this exception will be + thrown if the optional library is needed, however is unavailable. + """ + + +class NotImplementedError(RestflyException): # noqa: PLW0622 + """ + In situations where something is stubbed out or otherwise not yet + implemented, this error can be thrown back to inform the user that the + requestion method, class, etc. is not yet developed. + """ + + +# The following Exception codes have been written using the following link as +# a baseline: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + + +class APIError(RestflyException): + """ + The APIError Exception is a generic Exception for handling responses from + the API that aren't whats expected. The APIError Exception itself attempts + to provide the developer with enough information around the response to + ascertain what went wrong. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = False + retries = None + + def __init__(self, resp, **kwargs): + kwargs['func'] = kwargs.get('func', api_error_func) + self.response = resp + self.code = resp.status_code + self.retries = kwargs.get('retries') + super().__init__(resp, **kwargs) + + @classmethod + def set_retryable(cls, value: bool) -> None: + """ + Sets the retry flag for the given response code. + """ + cls.retryable = value + + +class BadRequestError(APIError): # 400 Response + """ + The server cannot or will not process the request due to an apparent client + error (e.g., malformed request syntax, size too large, invalid request + message framing, or deceptive request routing). + + Typically associated with a ``400`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnauthorizedError(APIError): # 401 Response + """ + Similar to 403 Forbidden, but specifically for use when authentication is + required and has failed or has not yet been provided. The response must + include a WWW-Authenticate header field containing a challenge applicable + to the requested resource. See Basic access authentication and Digest + access authentication. 401 semantically means "unauthenticated", i.e. the + user does not have the necessary credentials. + + Typically associated with a ``401`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ForbiddenError(APIError): # 403 Response + """ + The request was valid, but the server is refusing action. The user might + not have the necessary permissions for a resource, or may need an account + of some sort. + + Typically associated with a ``403`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NotFoundError(APIError): # 404 Response + """ + The requested resource could not be found but may be available in the + future. Subsequent requests by the client are permissible. + + Typically associated with a ``404`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class InvalidMethodError(APIError): # 405 Response + """ + A request method is not supported for the requested resource; for example, + a GET request on a form that requires data to be presented via POST, or a + PUT request on a read-only resource. + + Typically associated with a ``405`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NotAcceptableError(APIError): # 406 Response + """ + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + + Typically associated with a ``406`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ProxyAuthenticationError(APIError): # 407 Response + """ + The client must first authenticate itself with the proxy. + + Typically associated with a ``407`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RequestTimeoutError(APIError): # 408 Response + """ + The client did not produce a request within the time that the server was + prepared to wait. The client MAY repeat the request without modifications + at any later time. + + Typically associated with a ``408`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RequestConflictError(APIError): # 409 Response + """ + Indicates that the request could not be processed because of conflict in + the current state of the resource, such as an edit conflict between + multiple simultaneous updates. + + Typically associated with a ``409`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NoLongerExistsError(APIError): # 410 Response + """ + Indicates that the resource requested is no longer available and will not + be available again. This should be used when a resource has been + intentionally removed and the resource should be purged. Upon receiving a + 410 status code, the client should not request the resource in the future. + Clients such as search engines should remove the resource from their + indices. Most use cases do not require clients and search engines to purge + the resource, and a "404 Not Found" may be used instead. + + Typically associated with a ``410`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class LengthRequiredError(APIError): # 411 Response + """ + The request did not specify the length of its content, which is required by + the requested resource. + + Typically associated with a ``411`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PreconditionFailedError(APIError): # 412 Response + """ + The server does not meet one of the preconditions that the requester put + on the request. + + Typically associated with a ``412`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PayloadTooLargeError(APIError): # 413 Response + """ + The request is larger than the server is willing or able to process. + + Typically associated with a ``413`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class URITooLongError(APIError): # 414 Response + """ + The URI provided was too long for the server to process. Often the result + of too much data being encoded as a query-string of a GET request, in which + case it should be converted to a POST request. + + Typically associated with a ``414`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnsupportedMediaTypeError(APIError): # 415 Response + """ + The request entity has a media type which the server or resource does not + support. For example, the client uploads an image as image/svg+xml, but the + server requires that images use a different format. + + Typically associated with a ``415`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RangeNotSatisfiableError(APIError): # 416 Response + """ + The client has asked for a portion of the file (byte serving), but the + server cannot supply that portion. For example, if the client asked for a + part of the file that lies beyond the end of the file. + + Typically associated with a ``416`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ExpectationFailedError(APIError): # 417 Response + """ + The server cannot meet the requirements of the Expect request-header field. + + Typically associated with a ``417`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TeapotResponseError(APIError): # 418 Response + """ + This code was defined in 1998 as one of the traditional IETF April Fools' + jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, and is not + expected to be implemented by actual HTTP servers. The RFC specifies this + code should be returned by teapots requested to brew coffee. + + Typically associated with a ``418`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class MisdirectRequestError(APIError): # 421 Response + """ + The request was directed at a server that is not able to produce a response + + Typically associated with a ``421`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class InvalidContentError(APIError): + """ + The request contained content that did not match the expected schema or was + otherwise invalid in some way. + + Typically associated with a ``422`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (requests.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TooEarlyError(APIError): # 425 Response + """ + Indicates that the server is unwilling to risk processing a request that + might be replayed. + + Typically associated with a ``425`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UpgradeRequiredError(APIError): # 426 Response + """ + The client should switch to a different protocol such as TLS/1.0, given in + the Upgrade header field. + + Typically associated with a ``426`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PreconditionRequiredError(APIError): # 428 Response + """ + The origin server requires the request to be conditional. Intended to + prevent the 'lost update' problem, where a client GETs a resource's state, + modifies it, and PUTs it back to the server, when meanwhile a third party + has modified the state on the server, leading to a conflict. + + Typically associated with a ``428`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TooManyRequestsError(APIError): # 420 & 429 Response + """ + The user has sent too many requests in a given amount of time. Intended for + use with rate-limiting schemes. + + Typically associated with a ``429`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class RequestHeaderFieldsTooLargeError(APIError): # 431 Response + """ + The server is unwilling to process the request because either an individual + header field, or all the header fields collectively, are too large. + + Typically associated with a ``431`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnavailableForLegalReasonsError(APIError): # 451 Response + """ + A server operator has received a legal demand to deny access to a resource + or to a set of resources that includes the requested resource. + + Typically associated with a ``451`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ServerError(APIError): # 500 Response + """ + A generic error message, given when an unexpected condition was encountered + and no more specific message is suitable. + + Typically associated with a ``500`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class MethodNotImplementedError(APIError): # 501 Response + """ + The server either does not recognize the request method, or it lacks the + ability to fulfil the request. Usually this implies future availability. + + Typically associated with a ``501`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class BadGatewayError(APIError): # 502 Response + """ + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + + Typically associated with a ``502`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class ServiceUnavailableError(APIError): # 503 Response + """ + The server cannot handle the request (because it is overloaded or down for + maintenance). Generally, this is a temporary state. + + Typically associated with a ``503`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class GatewayTimeoutError(APIError): # 504 Response + """ + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + + Typically associated with a ``504`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class NotExtendedError(APIError): # 510 Response + """ + Further extensions to the request are required for the server to fulfil it. + + Typically associated with a ``510`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NetworkAuthenticationRequiredError(APIError): # 511 Response + """ + The client needs to authenticate to gain network access. Intended for use + by intercepting proxies used to control access to the network + + Typically associated with a ``511`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ diff --git a/tenable/base/_restfly_v1.py b/tenable/base/_restfly_v1.py new file mode 100644 index 000000000..737090e34 --- /dev/null +++ b/tenable/base/_restfly_v1.py @@ -0,0 +1,1673 @@ +""" +Vendored version of RESTFly 1.5.1 with some minor tweaks in order to support the subset +of the application thats needed. This is not expected to be called directly and instead +intended to be called from the platform and endpoint modules. +""" + +from __future__ import annotations + +import json +import logging +import platform +import re +import sys +import time +import warnings +from collections.abc import MutableMapping +from copy import copy +from typing import Any, Self +from urllib.parse import urlparse + +import arrow +from box import Box, BoxList +from requests import Response, Session +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, +) +from requests.exceptions import ( + RequestException as RequestsRequestException, +) + +from tenable import errors + + +def format_json_response( + response: Response, + box_attrs: dict[str, Any] | None = None, + conv_json: bool = True, + conv_box: bool = True, +) -> Response | dict[str, Any] | list | Box | BoxList: + """ + A simple utility to handle formatting the response object into either a + Box object or a Python native object from the JSON response. The function + will prefer box over python native if both flags are set to true. If none + of the flags are true, or if the content-type header reports as something + other than "application/json", then the response object is instead + returned. + + Args: + response: + The response object that will be checked against. + box_attrs: + The optional box attributed to pass as part of instantiation. + conv_json: + A flag handling if we should run the JSON conversion to python + native data-types. + conv_box: + A flag handling if we should convert the data to a Box object. + + Returns: + box.Box: + If the conv_box flag is True, and the response is a single object, + then the response is a Box obj. + box.BoxList: + If the conv_box flag is True, and the response is a list of + objects, then the response is a BoxList obj. + dict: + If the conv_json flag is True and the conv_box is False, and the + response is a single object, then the response is a dict obj. + list: + If the conv_json flag is True and conv_box is False, and the + response is a list of objects, then the response is a list obj. + requests.Response: + If neither flag is True, or if the response isn't JSON data, then + a response object is returned (pass-through). + """ + content_type = response.headers.get('content-type', 'application/json') + if ( + (conv_json or conv_box) + and 'application/json' in content_type.lower() + and len(response.text) > 0 + ): # noqa: E124 + if conv_box: + data = response.json() + if isinstance(data, list): + return BoxList(data, **box_attrs) + elif isinstance(data, dict): + return Box(data, **box_attrs) + elif conv_json: + return response.json() + return response + + +def url_validator(url: str, validate: list[str] | None = None) -> bool: + """ + Validates that the required URL Parts exist within the URL string. + + Args: + url (string): + The URL to process. + validate (list[str], optional): + The URL parts to validate are non-empty. + + Examples: + >>> url_validator('https://google.com') # Returns True + >>> url_validator('google.com') #Returns False + >>> url_validator( + ... 'https://httpbin.com/404', + ... validate=['scheme', 'netloc', 'path']) + # Returns True + """ + if not validate: + validate = ['scheme', 'netloc'] + resp = urlparse(url)._asdict() + for val in validate: + if val not in resp or resp[val] == '': + return False + return True + + +def dict_flatten( + dct: dict, + sep: str = '.', + parent_key: str | None = None, + lower_key: bool = False, +) -> dict: + """ + Flattens a nested dict. + + Args: + dct (dict): + The dictionary to flatten + parent_key: (str, optional): + An optional prefix key to add to all entries within the base dictionary. + sep (str, optional): + The separation character. If left unspecified, the default is '.'. + lower_key (bool, optional): + If ``True``, will lowercase the keys. + + Examples: + >>> x = {'a': 1, 'b': {'c': 2}} + >>> dict_flatten(x) + {'a': 1, 'b.c': 2} + + inspired by `this `_ Stackoverflow answer. + """ + items = [] + for key, val in dct.items(): + new_key = f'{parent_key}{sep}{key}' if parent_key else key + if lower_key: + new_key = new_key.lower() + if isinstance(val, MutableMapping): + items.extend( + dict_flatten( + val, parent_key=new_key, sep=sep, lower_key=lower_key + ).items() + ) + elif isinstance(val, list): + items.append( + ( + new_key, + [ + dict_flatten(i, sep=sep, lower_key=lower_key) + if isinstance(i, dict) + else i + for i in val + ], + ) + ) + else: + items.append((new_key, val)) + return dict(items) + + +def dict_clean(dct: dict) -> dict: + """ + Recursively removes dictionary keys where the value is None + + Args: + d (dict): + The dictionary to clean + + Returns: + :obj:`dict`: + The cleaned dictionary + + Examples: + >>> x = {'a': 1, 'b': {'c': 2, 'd': None}, 'e': None} + >>> clean_dict(x) + {'a': 1, 'b': {'c': 2}} + """ + clean = {} + for key, value in dct.items(): + # if the value is a dictionary, then we will recursively clean. + if isinstance(value, dict): + new_value = dict_clean(value) + if len(new_value.keys()) > 0: + clean[key] = new_value + + # if the value is a list, we will check for any dictionaries within + # the list and recursively clean. + elif isinstance(value, list): + new_value = [] + for item in value: + if isinstance(item, dict): + new_item = dict_clean(item) + if len(new_item.keys()) > 0: + new_value.append(new_item) + else: + new_value.append(item) + clean[key] = new_value + + # if the value isn't None, then store the value under the key. + elif value is not None: + clean[key] = value + + return clean + + +def dict_merge(master: dict, *updates: dict) -> dict: + """ + Merge many dictionaries together The updates dictionaries will be merged + into sthe master, adding/updating any values as needed. + + .. warning:: + This function is no longer necessary and will be removed in a later version + of RESTfly. For a more pythonic approach to handle this, please refer to + `PEP-584 `_ for modern approaches on merging + dictionaries. + + Args: + master (dict): + The master dictionary to be used as the base. + *updates (list[dict]): + The dictionaries that will overload the values in the master. + + Returns: + :obj:`dict`: + The merged dictionary + + Examples: + >>> a = {'one': 1, 'two': 2, 'three': {'four': 4}} + >>> b = {'a': 'a', 'three': {'b': 'b'}} + >>> dict_merge(a, b) + {'a': 'a', 'one': 1, 'two': 2, 'three': {'b': b, 'four': 4}} + """ + warnings.warn( + 'This function is no longer necessary and will be removed in a later version ' + 'of RESTfly. For a more pythonic approach to handle this, please refer to ' + 'PEP-584.', + DeprecationWarning, + stacklevel=2, + ) + for update in updates: + for key in update: + if ( + key in master + and isinstance(master[key], dict) + and isinstance(update[key], dict) + ): + master[key] = dict_merge(master[key], update[key]) + else: + master[key] = update[key] + return master + + +def force_case(obj: Any, case: str | None = None) -> Any: + """ + A simple case enforcement function. + + Args: + obj (Object): object to attempt to enforce the case upon. + + Returns: + :obj:`obj`: + The modified object + + Examples: + A list of mixed types: + + >>> a = ['a', 'list', 'of', 'strings', 'with', 'a', 1] + >>> force_Case(a, 'upper') + ['A', 'LIST', 'OF', 'STRINGS', 'WITH', 'A', 1] + + A simple string: + + >>> force_case('This is a TEST', 'lower') + 'this is a test' + + A non-string item that'll pass through: + + >>> force_case(1, 'upper') + 1 + """ + if case == 'lower': + if isinstance(obj, list): + return [i.lower() for i in obj if isinstance(i, str)] + elif isinstance(obj, str): + return obj.lower() + + elif case == 'upper': + if isinstance(obj, list): + return [i.upper() for i in obj if isinstance(i, str)] + elif isinstance(obj, str): + return obj.upper() + + return obj + + +def redact_values( + obj: dict[str, Any], keys: list[str] | None = None, value: str = 'REDACTED' +) -> dict[str, Any]: + """ + Redacts the values of the keys specified. Useful in logging so that + sensitive fields are not presented to the logs. + + Args: + obj (dict): + The object upon which redaction will happen. + keys (list[str], optional): + The list of key names that should be redacted. + value (str, optional): + The redacted value to use in place of the sensitive information. + + Returns: + :obj:`obj`: + The modified object. + """ + if not keys: + keys = [] + new = copy(obj) + for key in new: + if isinstance(new[key], dict): + new[key] = redact_values(new[key], keys=keys) + elif key in keys: + new[key] = value + return new + + +def trunc(text: str, limit: int, suffix: str | None = '...') -> str: + """ + Truncates a string to a given number of characters. If a string extends + beyond the limit, then truncate and add an ellipses after the truncation. + + Args: + text (str): The string to truncate + limit (int): The maximum limit that the string can be. + suffix (str): + What suffix should be appended to the truncated string when we + truncate? If left unspecified, it will default to ``...``. + + + Returns: + :obj:`str`: + The truncated string + + Examples: + A simple truncation: + + >>> trunc('this is a test', 6) + 'thi...' + + Truncating with no suffix: + + >>> trunc('this is a test', 6, suffix=None) + 'this i' + + Truncating with a custom suffix: + + >>> trunc('this is a test', 6, suffix='->') + 'this->' + """ + if len(text) >= limit: + if isinstance(suffix, str): # noqa: PLR1705 + # If we have a suffix, then reduce the text string length further + # by the length of the suffix and then concatenate both the text + # and suffix together. + return f'{text[: limit - len(suffix)]}{suffix}' + else: + # If no suffix, then simply reduce the string size. + return text[:limit] + return text + + +def check(name: str, obj: Any, expected_type: Any, **kwargs) -> Any: + """ + Check function for validating that inputs we are receiving are of the right + type, have the expected values, and can handle defaults as necessary. + + Args: + name (str): The name of the object (for exception reporting) + obj (obj): The object that we will be checking + expected_type (type): + The expected type of object that we will check against. + choices (list, optional): + if the object is only expected to have a finite number of values + then we can check to make sure that our input is one of these + values. + default (obj, optional): + if we want to return a default setting if the object is None, + we can set one here. + case (str, optional): + if we want to force the object values to be upper or lower case, + then we will want to set this to either ``upper`` or ``lower`` + depending on the desired outcome. The returned object will then + also be in the specified case. + pattern (str, optional): + Specify a regex pattern from the pattern map variable. + pattern_map (dict, optional): + Any additional items to add to the pattern mapping. + regex (str, optional): + Validate that the value of the object matches this pattern. + items_type (type, optional): + If the expected type is an iterable, and if all of the items + within that iterable are expected to be a given type, then + specifying the type here will enable checking each item within + the iterable. + NOTE: this will traverse the iterable and return a list object. + softcheck (bool, optional): + If the variable is a string type + + Returns: + :obj:`Object`: + Either the object or the default object depending. + + Examples: + Ensure that the value is an integer type: + + >>> check('example', val, int) + + Ensure that the value of val is within 0 and 100: + + >>> check('example', val, int, choices=list(range(100))) + """ + + def validate_regex_pattern(regex, obj): + if isinstance(obj, str) and len(re.findall(regex, str(obj))) <= 0: + raise errors.UnexpectedValueError( + f'{name} has value of {obj}. Does not match pattern {regex}' + ) + + def validate_choice_list(choices, obj): + if obj not in choices: + raise errors.UnexpectedValueError( + ( + f'{name} has value of {obj}. Expected one of ' + f'{",".join([str(i) for i in choices])}' + ) + ) + + def validate_expected_type(expected, obj, softcheck=True): + # We need to conditionally set the expected name type local var based + # on if the expected type has a __name__ attribute. + if hasattr(expected, '__name__'): + exp = expected_type.__name__ + else: + exp = expected + + if isinstance(obj, expected): + # if everything matches, then just return the object + return obj + elif expected == arrow.Arrow: + return arrow.get(obj) + elif softcheck and isinstance(obj, str) and expected not in [list, tuple]: + # if the expected type is not a list or tuple and it is a + # string type, then we will attempt to recast the object + # to be the expected type. + try: + new_obj = expected(obj) + except Exception as err: + # if the recasting fails, then just pass through. + raise TypeError( + (f'{name} is of type {obj.__class__.__name__}. Expected {exp}') + ) from err + else: + if expected is bool: + # if the expected type was boolean, then we will + # want to ensure that the string is one of the + # allowed values. From there we will set the + # object to be either True or False. in either case + # we will also want to make sure to set the + # type_pass flag to ensure we don't raise a + # TypeError later on. + if obj.lower() in ['true', 'false', 'yes', 'no']: + return obj.lower() in ['true', 'yes'] + else: + # In every other case, just set the object to be the + # recasted object and set the type_pass flag. + return new_obj + raise TypeError( + (f'{name} is of type {obj.__class__.__name__}. Expected {exp}') + ) + + def validate_normalized(obj, func, arg): + if isinstance(obj, (list, tuple)): + # If the object is a list or tuple type, then lets ensure that + # all of the items within the obj . + for item in obj: + func(arg, item) + else: + func(arg, obj) + + pmap = dict_merge( + { + 'uuid': ( + r'^[0-9a-f]{8}-' + r'[0-9a-f]{4}-' + r'[0-9a-f]{4}-' + r'[0-9a-f]{4}-' + r'[0-9a-f]{12}$' + ), + 'email': r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', + 'hex': r'^[a-fA-f0-9]+$', + 'url': ( + r'^(https?:\/\/)?' + r'([\da-z\.-]+)\.' + r'([a-z\.]{2,6})([\/\w \.-]*)*\/?$' + ), + 'ipv4': r'^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$', + 'ipv6': ( + r'(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|' + r'([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:' + r'[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}' + r'(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}' + r'(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}' + r'(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}' + r'(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:' + r'((:[0-9a-fA-F]{1,4}){1,6})|:' + r'((:[0-9a-fA-F]{1,4}){1,7}|:)|' + r'fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::' + r'(ffff(:0{1,4}){0,1}:){0,1}' + r'((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}' + r'(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|' + r'([0-9a-fA-F]{1,4}:){1,4}:' + r'((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}' + r'(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))' + ), + }, + kwargs.get('pattern_map', {}), + ) + + # We have a simple function to convert the case of string values so that + # we can ensure correct output. + + # Convert the case of the inputs. + obj = force_case(obj, kwargs.get('case')) + kwargs['choices'] = force_case(kwargs.get('choices'), kwargs.get('case')) + kwargs['default'] = force_case(kwargs.get('default'), kwargs.get('case')) + + # If the object sent to us has a None value, then we will return None. + # If a default was set, then we will return the default value. + allow_none = kwargs.get('allow_none', True) + if obj is None and allow_none: # noqa: PLR1705 + return kwargs.get('default') + + # If the allow_none keyword was passed and set to False, we should raise an + # unexpected value error if none was seen. + elif obj is None and not allow_none: + raise errors.UnexpectedValueError(f'{name} has no value.') + + # If the object is none of the right types then we want to raise a + # TypeError as it was something we weren't expecting. + obj = validate_expected_type(expected_type, obj, kwargs.get('softcheck', True)) + + if kwargs.get('items_type'): + # If the items within the list should also be of a specific type, + # we can check those as well + lobj = [] + for item in obj: + lobj.append( + validate_expected_type( + kwargs.get('items_type'), item, kwargs.get('softcheck', True) + ) + ) + obj = lobj + + # if the object is only expected to have one of a finite set of values, + # we should check against that and raise an exception if the the actual + # value is outside of what we expect. + if kwargs.get('choices'): + validate_normalized(obj, validate_choice_list, kwargs.get('choices')) + + # If a pattern was specified, then we will want to pull the pattern from + # the pattern map and validate that the + if kwargs.get('pattern') and kwargs.get('pattern') in pmap: + validate_normalized(obj, validate_regex_pattern, pmap[kwargs.get('pattern')]) + + # If there wasn't a pattern matching that identifier, then throw an + # IndexError + elif kwargs.get('pattern') and kwargs.get('pattern') not in pmap.keys(): + raise IndexError(f'pattern name {kwargs.get("pattern")} not found in map') + + # If a raw regex pattern was provided instead, then we will pass that over + # and validate + elif kwargs.get('regex'): + validate_normalized(obj, validate_regex_pattern, kwargs.get('regex')) + + # if we made it this gauntlet without an exception being raised, then + # assume everything is good to go and return the object passed to us + # initially. + return obj + + +class APIIterator: + """ + The API iterator provides a scalable way to work through result sets of any + size. The iterator will walk through each page of data, returning one + record at a time. If it reaches the end of a page of records, then it will + request the next page of information and then continue to return records + from the next page (and the next, and the next) until the counter reaches + the total number of records that the API has reported. + + Note that this Iterator is used as a base model for all of the iterators, + and while the mechanics of each iterator may vary, they should all behave + to the user in a similar manner. + + Attributes: + _api (restfly.session.APISession): + The APISession object that will be used for querying for the + data. + count (int): + The current number of records that have been returned + max_items (int): + The maximum number of items to return before stopping iteration. + max_pages (int): + The maximum number of pages to request before throwing stopping + iteration. + num_pages (int): + The number of pages that have been requested. + page (list): + The current page of data being walked through. pages will be + cycled through as the iterator requests more information from the + API. + page_count (int): The number of record returned from the current page. + total (int): + The total number of records that exist for the current request. + """ + + count: int = 0 + page_count: int = 0 + num_pages: int = 0 + max_pages: int | None = None + max_items: int | None = None + total: int | None = None + page: list[Any] = [] + _api: APISession + + def __init__(self, api, **kw): + """ + Args: + api (restfly.session.APISession): + The APISession object to use for this iterator. + **kw (dict): + The various attributes to add/overload in the iterator. + + Example: + >>> i = APIIterator(api, max_pages=1, max_items=100) + """ + self._api = api + self.__dict__.update(kw) + + # Create the logging facility + self._log = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}') + + def _increment_counters(self) -> None: + """ + Handles incrementing all of the counters that are controlling the next item + to be retreived. + """ + self.count += 1 + self.page_count += 1 + + def _get_next_item(self) -> Any: + """ + Returns the next item in the page + """ + return self[self.page_count] + + def _get_page(self) -> None: + """ + A method to be overloaded in order to instruct the iterator how to + retrieve the next page of data. + + Example: + >>> class ExampleIterator(APIIterator): + ... def _get_page(self): + ... self.total = 100 + ... items = range(10) + ... self.page = [{'id': i + self._offset} for i in items] + ... self._offset += self._limit + """ + + def __getitem__(self, key: int) -> Any: + return self.page[key] + + def __iter__(self) -> Self: + return self + + def __next__(self) -> Any: + return self.next() # noqa: PLE1102 + + def get(self, key: int, default: Any | None = None) -> Any: + """ + Retrieves an item from the the current page based off of the key. + + Args: + key (int): The index of the item to retrieve. + default (obj): The returned object if the item does not exist. + + Examples: + >>> a = APIIterator() + >>> a.get(2) + None + """ + try: + return self[key] + except IndexError: + return default + + def next(self) -> Any: + """ + Ask for the next record + """ + # If there are no more records to return, then we should raise a + # StopIteration exception to break the iterator out. + if ( + (self.total and self.count + 1 > self.total) # noqa: PLR0916 + or (self.max_items and self.count >= self.max_items) + ): + raise StopIteration() + + # If we have worked through the current page of records and we still + # haven't hit to the total number of available records, then we should + # query the next page of records. + if self.page_count >= len(self.page) and ( + not self.total or self.count + 1 <= self.total + ): + # If the number of pages requested reaches the total number of + # pages that should be requested, then stop iteration. + if self.max_pages and self.num_pages + 1 > self.max_pages: + raise StopIteration() + + # Perform the _get_page call. + self._get_page() + self.page_count = 0 + self.num_pages += 1 + + # If the length of the page is 0, then we don't have anything + # further to do and should stop iteration. + if len(self.page) == 0: + raise StopIteration() + + # Get the relevant record, increment the counters, and return the + # record. + item = self._get_next_item() + self._increment_counters() + return item + + +class APISession: + """ + The APISession class is the base model for APISessions for different + products and applications. This is the model that the APIEndpoints + will be grafted onto and supports some basic wrapping of standard HTTP + methods on it's own. + + Attributes: + _box (bool): + Should responses be converted to Box objects automatically by + default? If left unspecified, the default is `False` + _build (str): + The build number/version of the integration. + _backoff (float): + The default backoff timer to use when retrying. The value is + either a float or integer denoting the number of seconds to delay + before the next retry attempt. The number will be multiplied by + the number of retries attempted. + _base_error_map (dict): + The error mapping detailing what HTTP response code should throw + what kind of error. As this is the base mapping, overloading this + would remove any pre-set error mappings. + _error_map (dict): + The error mapping detailing what HTTP response code should throw + what kind of error. This error map will overload specific error + mappings. + _error_on_unexpected_input (bool): + If unexpected keywords have been passed to the session constructor, + should we raise an error? Default is ``False``. + _lib_name (str): + The name of the library. + _lib_version (str): + The version of the library. + _product (str): + The product name for the integration. + _proxies (dict): + A dictionary detailing what proxy should be used for what transport + protocol. This value will be passed to the session object after it + has been either attached or created. For details on the structure + of this dictionary, consult the + :requests:`Requests documentation.` + _restricted_paths (list[str]): + A list of paths (not complete URIs) that if seen be the + :obj:`_req` method will not pass the query params or the + request body into the logging facility. This should generally be + used for paths that are sensitive in nature (such as logins). + _retries (int): + The number of retries to make before failing a request. The + default is 3. + _session (requests.Session): + Provide a pre-built session instead of creating a requests session + at instantiation. + _ssl_verify (bool): + Should SSL verification be performed? If not, then inform requests + that we don't want to use SSL verification and suppress the SSL + certificate warnings. + _timeout (int): + The number of seconds to wait with no data returned before + declaring the request as stalled and timing-out the request. + _url (str): + The base URL path to use. This should generally be a string value + denoting the first half of the URI. For example, + ``https://httpbin.org`` or ``https://example.api.site/api/2``. The + :obj:`_req` method will join this string with the incoming path + to construct the complete URI. Note that the two strings will be + joined with a backslash ``/``. + _vendor (str): + The vendor name for the integration. + + Args: + adapter (Object, optional): + A Requests Session adapter to bind to the session object. + adapter_path (str, optional): + The URL that the adapter will bind to. + backoff (float, optional): + If a 429 response is returned, how much do we want to backoff + if the response didn't send a Retry-After header. + build (str, optional): + The build number to put into the User-Agent string. + product (str, optional): + The product name to put into the User-Agent string. + proxies (dict, optional): + A dictionary detailing what proxy should be used for what + transport protocol. This value will be passed to the session + object after it has been either attached or created. For + details on the structure of this dictionary, consult the + :requests:`proxies ` section of the + Requests documentation. + retries (int, optional): + The number of retries to make before failing a request. The + default is 3. + session (requests.Session, optional): + Provide a pre-built session instead of creating a requests + session at instantiation. + ssl_verify (bool, optional): + If SSL Verification needs to be disabled (for example when using + a self-signed certificate), then this parameter should be set to + ``False`` to disable verification and mask the Certificate + warnings. + url (str, optional): + The base URL that the paths will be appended onto. + vendor (str, optional): + The vendor name to put into the User-Agent string. + """ + + _url: str | None = None + _base_path: str = '' + _retries: int = 3 + _backoff: float = 1 + _proxies: dict | tuple | None = None + _cert: tuple[str, str] | None = None + _ssl_verify: bool = True + _lib_name: str = 'Restfly' + _lib_version: str = '1.5.2-embedded' + _restricted_paths: list = [] + _vendor: str = 'unknown' + _product: str = 'unknown' + _build: str = 'unknown' + _adapter: Any = None + _adapter_path: str | None = None + _timeout: int | None = None + _conv_json: bool = False + _box: bool = False + _box_attrs: dict = {} + _error_map: dict = {} + _error_on_unexpected_input: bool = False + _base_error_map: dict = { + 400: errors.BadRequestError, + 401: errors.UnauthorizedError, + 403: errors.ForbiddenError, + 404: errors.NotFoundError, + 405: errors.InvalidMethodError, + 406: errors.NotAcceptableError, + 407: errors.ProxyAuthenticationError, + 408: errors.RequestTimeoutError, + 409: errors.RequestConflictError, + 410: errors.NoLongerExistsError, + 411: errors.LengthRequiredError, + 412: errors.PreconditionFailedError, + 413: errors.PayloadTooLargeError, + 414: errors.URITooLongError, + 415: errors.UnsupportedMediaTypeError, + 416: errors.RangeNotSatisfiableError, + 417: errors.ExpectationFailedError, + 418: errors.TeapotResponseError, + 420: errors.TooManyRequestsError, + 421: errors.MisdirectRequestError, + 422: errors.InvalidContentError, + 425: errors.TooEarlyError, + 426: errors.UpgradeRequiredError, + 428: errors.PreconditionRequiredError, + 429: errors.TooManyRequestsError, + 431: errors.RequestHeaderFieldsTooLargeError, + 451: errors.UnavailableForLegalReasonsError, + 500: errors.ServerError, + 501: errors.MethodNotImplementedError, + 502: errors.BadGatewayError, + 503: errors.ServiceUnavailableError, + 504: errors.GatewayTimeoutError, + 510: errors.NotExtendedError, + 511: errors.NetworkAuthenticationRequiredError, + } + + def __enter__(self): + """ + Context Manager __enter__ built-in method. See PEP-343 for more + details. + """ + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + """ + Context Manager __exit__ built-in method. See PEP-343 for more details. + """ + return self._deauthenticate() + + def __init__(self, **kwargs): + # Construct the error map from the base mapping, then overload the map + # with anything specified in the error map parameter and then store the + # final result in the error map parameter. This allows for overloading + # specific items if necessary without having to re-construct the whole + # map. + self._error_map = {**self._base_error_map, **self._error_map} + + # Assign the kw arguments to the private attributes. + self._url = kwargs.pop('url', self._url) + self._base_path = kwargs.pop('base_path', self._base_path) + self._retries = int(kwargs.pop('retries', self._retries)) + self._backoff = float(kwargs.pop('backoff', self._backoff)) + self._proxies = kwargs.pop('proxies', self._proxies) + self._ssl_verify = kwargs.pop('ssl_verify', self._ssl_verify) + self._adapter_path = kwargs.pop('adapter_path', self._adapter_path) + self._adapter = kwargs.pop('adapter', self._adapter) + self._cert = kwargs.pop('cert', self._cert) + self._vendor = kwargs.pop('vendor', self._vendor) + self._product = kwargs.pop('product', self._product) + self._build = kwargs.pop('build', self._build) + self._error_func = kwargs.pop('error_func', errors.api_error_func) + self._timeout = kwargs.pop('timeout', self._timeout) + self._box = kwargs.pop('box', self._box) + self._box_attrs = kwargs.pop('box_attrs', self._box_attrs) + self._conv_json = kwargs.pop('conv_json', self._conv_json) + + # Create the logging facility + self._log = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}') + + # Initiate the session builder. + self._build_session(**kwargs) + self._authenticate(**kwargs) + + # if the _error_on_unexpected_input flag is set to True, then we will + # check to see if any values remain in the kwargs dict, and if so, then + # error to the caller with the remaining items. + if self._error_on_unexpected_input and len(kwargs.keys()) > 0: + raise errors.UnexpectedValueError( + 'The following keywords are invalid {kwargs.keys()}' + ) + + def _build_session(self, **kwargs) -> None: + """ + The session builder. User-agent strings, cookies, headers, etc that + should persist for the session should be initiated here. The session + builder is called as part of the APISession constructor. + + Args: + session (requests.Session, optional): + If a session object was passed to the constructor, then this + would contain a session, otherwise a new one is created. + + Returns: + :obj:`None` + + Examples: + Extending the session builder to use basic auth: + + >>> class ExampleAPI(APISession): + ... def _build_session(self, session=None): + ... super(APISession, self)._build_session(**kwargs) + ... self._session.auth = (self._username, self._password) + """ + uname = platform.uname() + # link up the session to either the one passed or create a new session. + self._session = kwargs.pop('session', Session()) + + # If proxy support is needed, update the proxies in the session. + if self._proxies: + self._session.proxies.update(self._proxies) + + # If the SSL verification is disabled then we will need to disable + # verification in the requests session and we also want to mask the + # certificate warnings. + if self._ssl_verify is False: + self._session.verify = self._ssl_verify + warnings.filterwarnings('ignore', 'Unverified HTTPS request') + + # If client certificate authentication is needed, then we should inject + # the certificate tuple into the session. + if self._cert: + self._session.cert = self._cert + + # if an adapter was specified for the Requests Session, then we should + # mount that adapter on to the Session object. + if self._adapter: + if not self._adapter_path: + self._adapter_path = f'{self._url}/{self._base_path}' + self._session.mount(self._adapter_path, self._adapter) + + # Update the User-Agent string with the information necessary. + py_version = '.'.join([str(i) for i in sys.version_info][0:3]) + opsys = uname[0] + arch = uname[-2] + self._session.headers.update( + { + 'User-Agent': ( + 'Integration/1.0 ' + f'({self._vendor}; {self._product}; Build/{self._build}) ' + f'{self._lib_name}/{self._lib_version} ' + f'(Restfly/1.5.2-embedded; Python/{py_version}; {opsys}/{arch})' + ) + } + ) + + def _authenticate(self, **kwargs): # stub + """ + Authentication stub. Overload this method with your authentication + mechanism if you with to support authentication at creation and + authentication as part of context management. Note that this is run + AFTER the session builder. + + Example: + >>> class ExampleAPISession(APISession): + ... def _authenticate(self, username, password): + ... self._session.auth = (username, password) + """ + + def _deauthenticate(self, **kwargs): # stub + """ + De-authentication stub. De-authentication is automatically run as part + of leaving context within the context manager. + + Example: + >>> class ExampleAPISession(APISession): + ... def _deauthenticate(self): + ... self.delete('session/token') + """ + + def _resp_error_check(self, response: Response, **kwargs) -> Response: + """ + If there is a need for additional error checking (for example within + the JSON response) then overload this method with the necessary + checking. + + Args: + response (request.Response): + The response object. + **kwargs (dict): + The request keyword arguments. + + Returns: + :obj:`requests.Response`: + The response object. + """ + return response + + def _retry_request( + self, + response: Response, + retries: int, + **kwargs, + ) -> dict: + """ + A method to be overloaded to return any modifications to the request + upon retries. By default just passes back what was send in the same + order. + + Args: + response (request.Response): + The response object + retries (int): + The number of retries that have been performed. + **kwargs (dict): + The keyword arguments that were passed to the request. + + Returns: + :obj:`dict`: + The keyword arguments + """ + return kwargs + + def _req( + self, method: str, path: str, **kwargs + ) -> Box | BoxList | Response | dict[str, Any] | list: + """ + The requests session base request method. This is considered internal + as it's generally recommended to use the bespoke methods for each HTTP + method. + + Args: + method (str): + The HTTP method + path (str): + The URI path to append to the base path. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + box (bool, optional): + A request-specific override as to if the response should + attempted to be converted into a Box object. + box_attrs (dict, optional): + A request-specific override with a list of key-values to + pass to the box constructor. + conv_json (bool, optional): + A request-specific override to automatically convert the + response fromJSON to native data-types. + redact_fields (list[str], optional): + A list of keys to redact in the response. Redaction is used + for the requests to the API as all of the fields are sent to + the debug logs. Note that redaction should be used with care + as it basically makes a copy fo the request in order to scrub + the values. + redact_value (str, optional): + The value to use to replace the redacted values with. + retry_on (list[int], optional): + A list of numeric response status codes to attempt retry on. + This behavior is additive to the retry parameter in the + exceptions. + use_base (bool, optional): + Should the base path be appended to the URL? if left + unspecified the default is `True`. + + Returns: + :obj:`requests.Response`: + The default behavior is to return the requests Response object. + + :obj:`box.Box` or :obj:`box.BoxList`: + If the `box` parameter is set, then the response object will + be converted to a Box object if the response contains a the + content type header of "application/json" + + :obj:`dict` or :obj:`list`: + If the ``conv_json`` parameter is set, then the response object + will be converted using the Response objects baked-in ``json()`` + method. + + :obj:`None`: + If either `conv_json` or `box` has been set, however the + response object has an empty response body, then `None` will + be returned instead. + + Examples: + + >>> api = APISession() + >>> resp = api._req('GET', '/') + """ + error_resp = None + retries = 0 + kwargs['verify'] = kwargs.get('verify', self._ssl_verify) + conv_json = kwargs.pop('conv_json', self._conv_json) + + # Ensure that the box variable is set to either Box or BoxList. Then + # we want to ensure that "box" is removed from the keyword list. + conv_box = kwargs.pop('box', self._box) + + # Similarly to the box var, we will want to do the same thing with the + # box_attrs keyword. + box_attrs = kwargs.pop('box_attrs', self._box_attrs) + + # If retry_on is specified, then we will populate the retry_codes + # variable with a list of numeric status codes to additionally retry + # on. This is helpful if the API in question doesn't always behave in + # a consistent manner. + retry_codes = kwargs.pop('retry_on', []) + + # While the number of retries is less than the retry limit, loop. As + # we will be returning from within the loop if we receive a successful + # response or a non-retryable error, the loop should only be handling + # the retries themselves. + while retries <= self._retries: + # Check to see if the path is a relative path or a full path If + # we were able to successfully parse a network location using + # urlparse, then we will assume that this is a full path and pass + # the URL as-is. If it's a relative path, then we will append the + # baseurl to the path. In either case, the constructed uri string + # is what we will be using for the rest of the method for making + # the actual calls. + if len(urlparse(path).netloc) > 0: + uri = path + elif kwargs.pop('use_base', True) and self._base_path: + uri = f'{self._url}/{self._base_path}/{path}' + else: + uri = f'{self._url}/{path}' + + # Here we will generate the debug log. As some of the values that + # may be sent to us could be sensitive in nature, we have multiple + # ways for the developer to inform us that the data may be + # sensitive, and to screen out that data from the debug logs. We + # will be working through that below. + rkeys = kwargs.pop('redact_fields', None) + rval = kwargs.pop('redact_value', 'REDACTED') + + # if the path itself is in the _restricted_paths list, then we will + # simply replace the body and params + if path in self._restricted_paths: + body, params = rval, rval + + # if the redact_fields keyword was passed, then we will make a + # shallow copy of the body and params and pass those to the + # redact_values utility function to replace the values for any + # matching keys to the redact_value. + elif rkeys: + body = redact_values(kwargs.get('json', {}), rkeys, rval) + params = redact_values(kwargs.get('params', {}), rkeys, rval) + + # if no redaction happens, then we will simply store the + # reference of the body and params in the body and params vars. + else: + body = kwargs.get('json', {}) + params = kwargs.get('params', {}) + + # And now we generate the log based on body and params that we have + # sanitized (or not). + self._log.debug( + 'Request: %s' + % json.dumps( + {'method': method, 'url': uri, 'params': params, 'body': body} + ) + ) + + # Make the call to the API and pull the status code. + try: + resp = self._session.request( + method, uri, timeout=self._timeout, **kwargs + ) + status = resp.status_code + + # Here we will catch any underlying exceptions thrown from the + # requests library, log them, iterate the retry counter, then + # release the attempt for the next iteration. + except (RequestsConnectionError, RequestsRequestException) as ereq: + self._log.error('Requests Library Error: %s', str(ereq)) + time.sleep(1) + retries += 1 + error_resp = ereq + + # The following code will run when a request successfully returned. + else: + if status in self._error_map: + # If a status code that we know about has returned, then we + # will want to raise the appropriate Error. + err = self._error_map[status] + error_resp = err(resp, retries=retries, func=self._error_func) + if err.retryable or status in retry_codes: + # If the APIError fetched is retryable, we will want to + # attempt to retry our call. If we see the + # "Retry-After" header, then we will respect that. If + # no "Retry-After" header exists, then we will use the + # _backoff attribute to build a back-off timer based on + # the number of retries we have already performed. + retries += 1 + time.sleep( + float( + resp.headers.get('retry-after', retries * self._backoff) + ) + ) + + # The need to potentially modify the request for + # subsequent calls is the whole reason that we aren't + # using the default Retry logic that urllib3 supports. + kwargs = self._retry_request(resp, retries, **kwargs) + continue + else: + raise error_resp + + elif status in range(200, 299): + # As everything looks ok, lets pass the response on to the + # error checker and then return the response. + resp = self._resp_error_check(resp, **kwargs) + return format_json_response( + response=resp, + box_attrs=box_attrs, + conv_json=conv_json, + conv_box=conv_box, + ) + else: + # If all else fails, raise an error stating that we don't + # even know whats happening. + raise errors.APIError(resp, retries=retries, func=self._error_func) + raise error_resp + + def get( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP GET request using the specified path. Refer to + :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.get('/') + """ + return self._req('GET', path, **kwargs) + + def post( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP POST request using the specified path. Refer to the + :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.post('/') + """ + return self._req('POST', path, **kwargs) + + def put( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP PUT request using the specified path. Refer to the + :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.put('/') + """ + return self._req('PUT', path, **kwargs) + + def patch( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP PATCH request using the specified path. Refer to the + :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.patch('/') + """ + return self._req('PATCH', path, **kwargs) + + def delete( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP DELETE request using the specified path. Refer to + the :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.delete('/') + """ + return self._req('DELETE', path, **kwargs) + + def head( + self, path: str, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + Initiates an HTTP HEAD request using the specified path. Refer to the + :obj:`requests.request` for more detailed information on what + keyword arguments can be passed: + + Args: + path (str): + The path to be appended onto the base URL for the request. + **kwargs (dict): + Keyword arguments to be passed to + :py:meth:`restfly.session.APISession._req`. + + Returns: + :obj:`requests.Response` or :obj:`box.Box` + If the request was informed to attempt to "boxify" the response + and the response was JSON data, then a Box will be returned. + In all other scenarios, a Response object will be returned. + + Examples: + >>> api = APISession() + >>> resp = api.head('/') + """ + return self._req('HEAD', path, **kwargs) + + +class APIEndpoint: # noqa: PLR0903 + """ + APIEndpoint is the base model for which all API endpoint classes are + sired from. The main benefit is the ability to use the http request methods that + are attached to this base class. This allows for keeping common CRUD-type calls + together with minimal manual URL munging. + + Attributes: + _path (str): + The URI path to append to the base path as is specified in the + APISession object. This can become quite useful if most of the + CRUD follows the same pathing. It is only used when using the + APIEndpoint verbs (_get, _post, _put, etc.). + _box (bool): + An endpoint-specific version of `APISession._box`. + _box_attrs (bool): + An endpoint-specific version of `APISession._box_attrs`. + _conv_json (bool): + An endpoint-specific version of `APISession._conv_json`. + + Args: + api (APISession): + The APISession (or sired child) instance that the endpoint will + be using to perform calls to the API. + """ + + _path: str | None = None + _box: bool | None = None + _conv_json: bool | None = None + _box_attrs: dict | None = None + + def __init__(self, api: APISession): + self._api = api + self._log = api._log + + def _req( + self, method: str, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession._req method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api._req``. + + Args: + method (str): + The HTTP method + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._req('GET', **kwargs) + """ + if self._box: + kwargs['box'] = kwargs.get('box', self._box) + if self._box_attrs: + kwargs['box_attrs'] = kwargs.get('box_attrs', self._box_attrs) + if self._conv_json: + kwargs['conv_json'] = kwargs.get('conv_json', self._conv_json) + new_path = '/'.join([p for p in [self._path, path] if p]) + return self._api._req(method, new_path, **kwargs) + + def _delete( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.delete method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.delete``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._delete(**kwargs) + """ + return self._req('DELETE', path, **kwargs) + + def _get( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.get method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.get``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._get(**kwargs) + """ + return self._req('GET', path, **kwargs) + + def _head( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.head method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.head``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._head(**kwargs) + """ + return self._req('HEAD', path, **kwargs) + + def _patch( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.patch method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.patch``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._patch(**kwargs) + """ + return self._req('PATCH', path, **kwargs) + + def _post( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.post method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.post``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._post(**kwargs) + """ + return self._req('POST', path, **kwargs) + + def _put( + self, path: str | None = None, **kwargs + ) -> list | dict[str, Any] | Box | BoxList | Response: + """ + An abstraction of the APISession.put method leveraging the local + APIEndpoint _path attribute as well. This isn't intended to be called + directly, and instead is offered as a shortcut for methods within the + endpoint to use instead of ``self._api.put``. + + Args: + path (str, optional): + The URI path to append to the base path and _path attribute. + **kwargs (dict): + The keyword arguments to pass to the requests lib. + + Examples: + >>> class Endpoint(APIEndpoint): + ... _path = 'test' + ... def list(**kwargs): + ... return self._put(**kwargs) + """ + return self._req('PUT', path, **kwargs) diff --git a/tenable/base/endpoint.py b/tenable/base/endpoint.py index 2aa2c07bd..ec64e001f 100644 --- a/tenable/base/endpoint.py +++ b/tenable/base/endpoint.py @@ -11,8 +11,8 @@ :inherited-members: ''' from typing import Any, List, Optional -from restfly.utils import check -from restfly import APIEndpoint as Base +from tenable.utils import check +from tenable.base._restfly_v1 import APIEndpoint as Base class APIEndpoint(Base): # noqa PLR0903 diff --git a/tenable/base/graphql.py b/tenable/base/graphql.py index fefff4dfb..efbe07b35 100644 --- a/tenable/base/graphql.py +++ b/tenable/base/graphql.py @@ -24,10 +24,10 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union -from gql import Client, GraphQLRequest, gql +from gql import Client, GraphQLRequest from gql.transport.requests import RequestsHTTPTransport from graphql import DocumentNode, GraphQLError, validate -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.version import version diff --git a/tenable/base/platform.py b/tenable/base/platform.py index 69de6af5c..ea50247fc 100644 --- a/tenable/base/platform.py +++ b/tenable/base/platform.py @@ -1,4 +1,4 @@ -''' +""" Base Platform ============= @@ -9,18 +9,21 @@ .. autoclass:: APIPlatform :members: :inherited-members: -''' +""" + +import inspect import os import warnings -import inspect -from restfly import APISession as Base + from tenable.errors import AuthenticationWarning from tenable.utils import url_validator from tenable.version import version +from ._restfly_v1 import APISession as Base + class APIPlatform(Base): - ''' + """ Base class for all API Platform packages. This class handles all of the base connection logic. @@ -70,7 +73,8 @@ class APIPlatform(Base): The base URL that the paths will be appended onto. vendor (str, optional): The vendor name to put into the User-Agent string. - ''' + """ + _lib_name = 'pyTenable' _lib_version = version _backoff = 1 @@ -84,7 +88,6 @@ class APIPlatform(Base): } def __init__(self, **kwargs): - # if the constructed URL isn't valid, then we will throw a ConnectionError # to inform the caller that something isn't right here. url = kwargs.get('url') @@ -107,29 +110,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) def _session_auth(self, username, password): - ''' + """ Default Session auth behavior - ''' - self.post('session', json={ - 'username': username, - 'password': password - }) + """ + self.post('session', json={'username': username, 'password': password}) self._auth_mech = 'session' def _key_auth(self, access_key, secret_key): - ''' + """ Default API Key Auth Behavior - ''' - self._session.headers.update({ - 'X-APIKeys': f'accessKey={access_key}; secretKey={secret_key}' - }) + """ + self._session.headers.update( + {'X-APIKeys': f'accessKey={access_key}; secretKey={secret_key}'} + ) self._auth_mech = 'keys' def _authenticate(self, **kwargs): - ''' + """ This method handles authentication for both API Keys and for session authentication. - ''' + """ # Here we are grafting the authentication functions into the keyword # arguments for later usage. If a function is provided in the keywords # under the key names below, we will use those instead. This should @@ -141,7 +141,8 @@ def _authenticate(self, **kwargs): # methods will always end in _auth, so we will simply inspect the platform # object and look for any methods that end with '_auth' auth_mechs = { - i[0]: i[1] for i in inspect.getmembers(self, predicate=inspect.ismethod) + i[0]: i[1] + for i in inspect.getmembers(self, predicate=inspect.ismethod) if '_auth' in i[0][-5:] } @@ -151,7 +152,7 @@ def _authenticate(self, **kwargs): for name in self._allowed_auth_mech_priority: auth[name] = { 'func': kwargs.get(f'{name}_auth_func', auth_mechs[f'_{name}_auth']), - 'params': {} + 'params': {}, } for param in self._allowed_auth_mech_params[name]: # for each param, we will attempt to get the value of the parameter, @@ -181,18 +182,20 @@ def _authenticate(self, **kwargs): # If we found no valid authentication mechanisms, then we should warn the user # that we weren't able to find anything and allow the caller to interact with # the unauthenticated session. - warnings.warn('Starting an unauthenticated session', - AuthenticationWarning) + warnings.warn( + 'Starting an unauthenticated session', AuthenticationWarning, stacklevel=2 + ) self._log.warning('Starting an unauthenticated session.') - def _deauthenticate(self, # noqa PLW0221 - method: str = 'DELETE', - path: str = 'session' - ): - ''' + def _deauthenticate( + self, # noqa PLW0221 + method: str = 'DELETE', + path: str = 'session', + ): + """ This method handles de-authentication. This is only necessary for session-based authentication. - ''' + """ if self._auth_mech == 'user': self._req(method, path) self._auth = {} diff --git a/tenable/dl/__init__.py b/tenable/dl/__init__.py index ba8176084..c5e6962df 100644 --- a/tenable/dl/__init__.py +++ b/tenable/dl/__init__.py @@ -1,17 +1,22 @@ -''' +""" Product Downloads ================= .. autoclass:: Downloads :members: -''' -from tenable.base.platform import APIPlatform -from box import BoxList +""" + +import os +import warnings from io import BytesIO -import os, warnings + +from box import BoxList + +from tenable.base.platform import APIPlatform + class Downloads(APIPlatform): - ''' + """ The Downloads object is the primary interaction point for users to interface with Downloads API via the pyTenable library. All of the API endpoint classes that have been written will be grafted onto this class. @@ -57,7 +62,8 @@ class Downloads(APIPlatform): >>> dl = Downloads( >>> vendor='Company Name', product='Widget', build='1.0.0') - ''' + """ + _box = True _env_base = 'TDL' _url = 'https://www.tenable.com' @@ -70,21 +76,23 @@ def __init__(self, api_token=None, **kwargs): super().__init__(**kwargs) def _authenticate(self, **kwargs): - ''' + """ Authentication method for Downloads API - ''' + """ if not kwargs.get('api_token'): - warnings.warn('Starting an unauthenticated session') + warnings.warn('Starting an unauthenticated session', stacklevel=2) self._log.warning('Starting an unauthenticated session.') else: - self._session.headers.update({ - 'Authorization': 'Bearer {token}'.format( - token=kwargs.get('api_token') - ) - }) + self._session.headers.update( + { + 'Authorization': 'Bearer {token}'.format( + token=kwargs.get('api_token') + ) + } + ) def list(self): - ''' + """ Lists the available content pages. :devportal:`API Endpoint Documentation ` @@ -97,11 +105,11 @@ def list(self): >>> pages = dl.list() >>> for page in pages: ... pprint(page) - ''' + """ return self.get('pages', box=BoxList) def details(self, page): - ''' + """ Retrieves the specific download items for the page requested. :devportal:`API Endpoint Documentation ` @@ -115,11 +123,11 @@ def details(self, page): Examples: >>> details = dl.details('nessus') - ''' + """ return self.get('pages/{}'.format(page)) def download(self, page, package, fobj=None): - ''' + """ Retrieves the requested package and downloads the file. :devportal:`API Endpoint Documentation ` @@ -139,12 +147,13 @@ def download(self, page, package, fobj=None): >>> with open('Nessus-latest.x86_64.rpm', 'wb') as pkgfile: ... dl.download('nessus', ... 'Nessus-8.3.0-es7.x86_64.rpm', pkgfile) - ''' + """ if not fobj: fobj = BytesIO() resp = self.get( - 'pages/{}/files/{}'.format(page, package), stream=True, box=False) + 'pages/{}/files/{}'.format(page, package), stream=True, box=False + ) # Lets stream the file into the file-like object... for chunk in resp.iter_content(chunk_size=1024): diff --git a/tenable/errors.py b/tenable/errors.py index 7fd39de66..4661a90ff 100644 --- a/tenable/errors.py +++ b/tenable/errors.py @@ -1,24 +1,717 @@ -''' +""" +.. autoexception:: RestflyException +.. autoexception:: UnexpectedValueError +.. autoexception:: RequiredParameterError +.. autoexception:: APIError +.. autoexception:: BadRequestError +.. autoexception:: UnauthorizedError +.. autoexception:: ForbiddenError +.. autoexception:: NotFoundError +.. autoexception:: InvalidMethodError +.. autoexception:: NotAcceptableError +.. autoexception:: ProxyAuthenticationError +.. autoexception:: RequestTimeoutError +.. autoexception:: RequestConflictError +.. autoexception:: NoLongerExistsError +.. autoexception:: LengthRequiredError +.. autoexception:: PreconditionFailedError +.. autoexception:: PayloadTooLargeError +.. autoexception:: URITooLongError +.. autoexception:: UnsupportedMediaTypeError +.. autoexception:: RangeNotSatisfiableError +.. autoexception:: ExpectationFailedError +.. autoexception:: TeapotResponseError +.. autoexception:: MisdirectRequestError +.. autoexception:: InvalidContentError +.. autoexception:: TooEarlyError +.. autoexception:: UpgradeRequiredError +.. autoexception:: PreconditionRequiredError +.. autoexception:: TooManyRequestsError +.. autoexception:: RequestHeaderFieldsTooLargeError +.. autoexception:: UnavailableForLegalReasonsError +.. autoexception:: ServerError +.. autoexception:: MethodNotImplementedError +.. autoexception:: BadGatewayError +.. autoexception:: ServiceUnavailableError +.. autoexception:: GatewayTimeoutError +.. autoexception:: NotExtendedError +.. autoexception:: NetworkAuthenticationRequiredError .. autoclass:: AuthenticationWarning .. autoclass:: FileDownloadError .. autoclass:: ImpersonationError .. autoclass:: PasswordComplexityError .. autoclass:: TioExportsError .. autoclass:: TioExportsTimeout -''' +""" + +import logging from typing import Optional -from restfly.errors import * + + +def api_error_func(resp, **kwargs): # noqa: PLW0613 + """ + Default message function for APIErrors + + Args: + resp (request.Response): + The HTTP response that caused the error to be thrown. + **kwargs (dict): + The keyword argument dictionary from the APIError + + Returns: + :obj:`str`: + The string message for the error. + """ + return ( + f'[{str(resp.status_code)}: {str(resp.request.method)}] ' + f'{str(resp.request.url)} body={str(resp.content)}' + ) + + +def base_msg_func(msg, **kwargs): # noqa: PLW0613 + """ + Default function used for RestflyException + + Args: + msg (str): + The message string to be returned + **kwargs (dict): + The keyword argument dictionary from the RestflyException + + Returns: + :obj:`str`: + The string message + """ + return str(msg) + + +class RestflyException(Exception): + """ + Base exception class that sets up logging and handles some basic + scaffolding for all other exception classes. This exception should never + be directly seen. + """ + + def __init__(self, msg, **kwargs): + self._log = logging.getLogger(f'{self.__module__}.{self.__class__.__name__}') + self.msg = kwargs.get('func', base_msg_func)(msg, **kwargs) + self._log.error(self.__str__()) + super().__init__() + + def __str__(self): + return str(self.msg) + + def __repr__(self): + return repr(self.__str__()) + + +class UnexpectedValueError(RestflyException): + """ + An unexpected value error is thrown whenever the value specified for a + parameter is outside the bounds of what is expected. For example, if the + parameter **a** is expected to have a value of 1, 2, or 3, and it is + instead passed a value of 0, then it is an unexpected value, and this + Exception should be thrown by the package. + """ + + +class RequiredParameterError(RestflyException): + """ + A Required Parameter error is thrown whenever the value specified for a + parameter is required to have a value other than `None`. + """ + + +class ConnectionError(RestflyException): # noqa: PLW0622 + """ + A connection-error is thrown only for products like Tenable.sc or Nessus, + where the application may be installed anywhere. This error is thrown if + we are unable to complete the initial connection or gather the basic + information about the application that is necessary. + """ class AuthenticationWarning(Warning): # noqa: PLW0622 - ''' + """ An authentication warning is thrown when an unauthenticated API session is initiated. - ''' + """ + + +class PackageMissingError(RestflyException): + """ + In situations where an optional library is needed, this exception will be + thrown if the optional library is needed, however is unavailable. + """ + + +class NotImplementedError(RestflyException): # noqa: PLW0622 + """ + In situations where something is stubbed out or otherwise not yet + implemented, this error can be thrown back to inform the user that the + requesting method, class, etc. is not yet developed. + """ + + +# The following Exception codes have been written using the following link as +# a baseline: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + + +class APIError(RestflyException): + """ + The APIError Exception is a generic Exception for handling responses from + the API that aren't whats expected. The APIError Exception itself attempts + to provide the developer with enough information around the response to + ascertain what went wrong. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = False + retries = None + + def __init__(self, resp, **kwargs): + kwargs['func'] = kwargs.get('func', api_error_func) + self.response = resp + self.code = resp.status_code + self.retries = kwargs.get('retries') + super().__init__(resp, **kwargs) + + @classmethod + def set_retryable(cls, value: bool) -> None: + """ + Sets the retry flag for the given response code. + """ + cls.retryable = value + + +class BadRequestError(APIError): # 400 Response + """ + The server cannot or will not process the request due to an apparent client + error (e.g., malformed request syntax, size too large, invalid request + message framing, or deceptive request routing). + + Typically associated with a ``400`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnauthorizedError(APIError): # 401 Response + """ + Similar to 403 Forbidden, but specifically for use when authentication is + required and has failed or has not yet been provided. The response must + include a WWW-Authenticate header field containing a challenge applicable + to the requested resource. See Basic access authentication and Digest + access authentication. 401 semantically means "unauthenticated", i.e. the + user does not have the necessary credentials. + + Typically associated with a ``401`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ForbiddenError(APIError): # 403 Response + """ + The request was valid, but the server is refusing action. The user might + not have the necessary permissions for a resource, or may need an account + of some sort. + + Typically associated with a ``403`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NotFoundError(APIError): # 404 Response + """ + The requested resource could not be found but may be available in the + future. Subsequent requests by the client are permissible. + + Typically associated with a ``404`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class InvalidMethodError(APIError): # 405 Response + """ + A request method is not supported for the requested resource; for example, + a GET request on a form that requires data to be presented via POST, or a + PUT request on a read-only resource. + + Typically associated with a ``405`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NotAcceptableError(APIError): # 406 Response + """ + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + + Typically associated with a ``406`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ProxyAuthenticationError(APIError): # 407 Response + """ + The client must first authenticate itself with the proxy. + + Typically associated with a ``407`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RequestTimeoutError(APIError): # 408 Response + """ + The client did not produce a request within the time that the server was + prepared to wait. The client MAY repeat the request without modifications + at any later time. + + Typically associated with a ``408`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RequestConflictError(APIError): # 409 Response + """ + Indicates that the request could not be processed because of conflict in + the current state of the resource, such as an edit conflict between + multiple simultaneous updates. + + Typically associated with a ``409`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NoLongerExistsError(APIError): # 410 Response + """ + Indicates that the resource requested is no longer available and will not + be available again. This should be used when a resource has been + intentionally removed and the resource should be purged. Upon receiving a + 410 status code, the client should not request the resource in the future. + Clients such as search engines should remove the resource from their + indices. Most use cases do not require clients and search engines to purge + the resource, and a "404 Not Found" may be used instead. + + Typically associated with a ``410`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class LengthRequiredError(APIError): # 411 Response + """ + The request did not specify the length of its content, which is required by + the requested resource. + + Typically associated with a ``411`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PreconditionFailedError(APIError): # 412 Response + """ + The server does not meet one of the preconditions that the requester put + on the request. + + Typically associated with a ``412`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PayloadTooLargeError(APIError): # 413 Response + """ + The request is larger than the server is willing or able to process. + + Typically associated with a ``413`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class URITooLongError(APIError): # 414 Response + """ + The URI provided was too long for the server to process. Often the result + of too much data being encoded as a query-string of a GET request, in which + case it should be converted to a POST request. + + Typically associated with a ``414`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnsupportedMediaTypeError(APIError): # 415 Response + """ + The request entity has a media type which the server or resource does not + support. For example, the client uploads an image as image/svg+xml, but the + server requires that images use a different format. + + Typically associated with a ``415`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class RangeNotSatisfiableError(APIError): # 416 Response + """ + The client has asked for a portion of the file (byte serving), but the + server cannot supply that portion. For example, if the client asked for a + part of the file that lies beyond the end of the file. + + Typically associated with a ``416`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ExpectationFailedError(APIError): # 417 Response + """ + The server cannot meet the requirements of the Expect request-header field. + + Typically associated with a ``417`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TeapotResponseError(APIError): # 418 Response + """ + This code was defined in 1998 as one of the traditional IETF April Fools' + jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, and is not + expected to be implemented by actual HTTP servers. The RFC specifies this + code should be returned by teapots requested to brew coffee. + + Typically associated with a ``418`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class MisdirectRequestError(APIError): # 421 Response + """ + The request was directed at a server that is not able to produce a response + + Typically associated with a ``421`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class InvalidContentError(APIError): + """ + The request contained content that did not match the expected schema or was + otherwise invalid in some way. + + Typically associated with a ``422`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (requests.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TooEarlyError(APIError): # 425 Response + """ + Indicates that the server is unwilling to risk processing a request that + might be replayed. + + Typically associated with a ``425`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UpgradeRequiredError(APIError): # 426 Response + """ + The client should switch to a different protocol such as TLS/1.0, given in + the Upgrade header field. + + Typically associated with a ``426`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class PreconditionRequiredError(APIError): # 428 Response + """ + The origin server requires the request to be conditional. Intended to + prevent the 'lost update' problem, where a client GETs a resource's state, + modifies it, and PUTs it back to the server, when meanwhile a third party + has modified the state on the server, leading to a conflict. + + Typically associated with a ``428`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class TooManyRequestsError(APIError): # 420 & 429 Response + """ + The user has sent too many requests in a given amount of time. Intended for + use with rate-limiting schemes. + + Typically associated with a ``429`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class RequestHeaderFieldsTooLargeError(APIError): # 431 Response + """ + The server is unwilling to process the request because either an individual + header field, or all the header fields collectively, are too large. + + Typically associated with a ``431`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class UnavailableForLegalReasonsError(APIError): # 451 Response + """ + A server operator has received a legal demand to deny access to a resource + or to a set of resources that includes the requested resource. + + Typically associated with a ``451`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class ServerError(APIError): # 500 Response + """ + A generic error message, given when an unexpected condition was encountered + and no more specific message is suitable. + + Typically associated with a ``500`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class MethodNotImplementedError(APIError): # 501 Response + """ + The server either does not recognize the request method, or it lacks the + ability to fulfill the request. Usually this implies future availability. + + Typically associated with a ``501`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class BadGatewayError(APIError): # 502 Response + """ + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + + Typically associated with a ``502`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class ServiceUnavailableError(APIError): # 503 Response + """ + The server cannot handle the request (because it is overloaded or down for + maintenance). Generally, this is a temporary state. + + Typically associated with a ``503`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class GatewayTimeoutError(APIError): # 504 Response + """ + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + + Typically associated with a ``504`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + retryable = True + + +class NotExtendedError(APIError): # 510 Response + """ + Further extensions to the request are required for the server to fulfill it. + + Typically associated with a ``510`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ + + +class NetworkAuthenticationRequiredError(APIError): # 511 Response + """ + The client needs to authenticate to gain network access. Intended for use + by intercepting proxies used to control access to the network + + Typically associated with a ``511`` Status code. + + Attributes: + code (int): + The HTTP response code from the offending response. + response (request.Response): + This is the Response object that had caused the Exception to fire. + """ class FileDownloadError(RestflyException): - ''' + """ FileDownloadError is thrown when a file fails to download. Attributes: @@ -30,22 +723,23 @@ class FileDownloadError(RestflyException): The resource that the file was requested from (e.g. "scans") resource_id (str): The identifier for the resource that was requested. - ''' + """ def __init__(self, resource: str, resource_id: str, filename: str): self.resource = str(resource) self.resource_id = str(resource_id) self.filename = str(filename) - self.msg = (f'resource {resource}:{resource_id} ' - f'requested file {filename} and has failed.' - ) + self.msg = ( + f'resource {resource}:{resource_id} ' + f'requested file {filename} and has failed.' + ) class TioExportsError(RestflyException): - ''' + """ When the exports APIs throw an error when processing an export, pyTenable will throw this error in turn to relay that context to the user. - ''' + """ def __init__(self, export: str, uuid: str, msg: Optional[str] = None): self.export = export @@ -57,9 +751,9 @@ def __init__(self, export: str, uuid: str, msg: Optional[str] = None): class TioExportsTimeout(TioExportsError): - ''' + """ When an export has been cancelled due to timeout, this error is thrown. - ''' + """ def __init__(self, export: str, uuid: str, msg: Optional[str] = None): msg = f'{export} export {uuid} has timed out.' @@ -67,7 +761,7 @@ def __init__(self, export: str, uuid: str, msg: Optional[str] = None): class ImpersonationError(APIError): - ''' + """ An ImpersonationError exists when there is an issue with user impersonation. @@ -81,11 +775,11 @@ class ImpersonationError(APIError): of tracking the request and the response through the Tenable.io infrastructure. In the case of Non-Tenable.io products, is simply an empty string. - ''' + """ class PasswordComplexityError(APIError): - ''' + """ PasswordComplexityError is thrown when attempting to change a password and the password complexity is insufficient. @@ -99,4 +793,4 @@ class PasswordComplexityError(APIError): of tracking the request and the response through the Tenable.io infrastructure. In the case of Non-Tenable.io products, is simply an empty string. - ''' + """ diff --git a/tenable/ie/ad_object/api.py b/tenable/ie/ad_object/api.py index 13657670d..a014c7a65 100644 --- a/tenable/ie/ad_object/api.py +++ b/tenable/ie/ad_object/api.py @@ -10,7 +10,7 @@ :members: ''' from typing import List, Dict, Mapping -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.ie.ad_object.schema import ADObjectSchema, ADObjectChangesSchema from tenable.ie.base.iterator import ADIterator from tenable.base.endpoint import APIEndpoint diff --git a/tenable/ie/alert/api.py b/tenable/ie/alert/api.py index 9691edfab..023757b15 100644 --- a/tenable/ie/alert/api.py +++ b/tenable/ie/alert/api.py @@ -9,7 +9,7 @@ .. autoclass:: AlertsAPI :members: ''' -from typing import List, Dict +from typing import Dict from tenable.ie.alert.schema import AlertSchema, AlertParamsSchema from tenable.ie.base.iterator import ADIterator from tenable.base.endpoint import APIEndpoint diff --git a/tenable/ie/base/iterator.py b/tenable/ie/base/iterator.py index 0cd7c30c9..68db52ba3 100644 --- a/tenable/ie/base/iterator.py +++ b/tenable/ie/base/iterator.py @@ -1,27 +1,11 @@ -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator class ADIterator(APIIterator): - ''' + """ The following methods allows us to iterate through pages and get data + """ - Attributes: - _api (restfly.session.APISession): - The APISession object that will be used for querying for the - data. - _path (str): - The URL for API call. - _schema (object): - The marshmallow schema object for deserialized response. - _method (str): - The API request method. supported values are ``get`` and ``post``. - default is ``get`` - _query (dict): - The query params for API call. - _payload (dict): - The payload object for API call. it is applicable only for - post method. - ''' _api = None _query = None _payload = None @@ -31,9 +15,9 @@ class ADIterator(APIIterator): _schema = None def _get_page(self) -> None: - ''' + """ Request the next page of data - ''' + """ # The first thing that we need to do is construct the query with the # current page and per_page query = self._query @@ -43,9 +27,9 @@ def _get_page(self) -> None: # Lets make the actual call at this point. if self._method == 'post': self.page = self._schema.load( - self._api.post(self._path, params=query, json=self._payload), - many=True) + self._api.post(self._path, params=query, json=self._payload), many=True + ) else: self.page = self._schema.load( - self._api.get(self._path, params=query), - many=True) + self._api.get(self._path, params=query), many=True + ) diff --git a/tenable/ie/deviance/api.py b/tenable/ie/deviance/api.py index 43ed87e01..a18777f6d 100644 --- a/tenable/ie/deviance/api.py +++ b/tenable/ie/deviance/api.py @@ -10,7 +10,7 @@ :members: ''' from typing import List, Dict, Union, Mapping -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.ie.base.iterator import ADIterator from tenable.ie.deviance.schema import DevianceSchema from tenable.base.endpoint import APIEndpoint diff --git a/tenable/ie/directories/api.py b/tenable/ie/directories/api.py index e8776c51b..6db8263b7 100644 --- a/tenable/ie/directories/api.py +++ b/tenable/ie/directories/api.py @@ -10,7 +10,7 @@ :members: ''' from typing import List, Dict -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint from .schema import DirectorySchema diff --git a/tenable/ie/event/api.py b/tenable/ie/event/api.py index a01d0438f..b89198fd6 100644 --- a/tenable/ie/event/api.py +++ b/tenable/ie/event/api.py @@ -9,7 +9,7 @@ :members: ''' from typing import Dict, List, Mapping -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.ie.event.schema import EventSchema from tenable.base.endpoint import APIEndpoint diff --git a/tenable/ie/roles/api.py b/tenable/ie/roles/api.py index 18a4afb63..89e1c674a 100644 --- a/tenable/ie/roles/api.py +++ b/tenable/ie/roles/api.py @@ -10,7 +10,6 @@ :members: ''' from typing import List, Dict -from marshmallow import ValidationError from tenable.base.endpoint import APIEndpoint from .schema import RoleSchema, RolePermissionsSchema diff --git a/tenable/ie/saml_configuration/api.py b/tenable/ie/saml_configuration/api.py index 4a85014a4..febbcaebf 100644 --- a/tenable/ie/saml_configuration/api.py +++ b/tenable/ie/saml_configuration/api.py @@ -88,4 +88,4 @@ def generate_saml_certificate(self) -> Dict: Examples: >>> tie.saml_configuration.generate_saml_certificate() ''' - return self._schema.load(self._get(f'generate-certificate')) + return self._schema.load(self._get('generate-certificate')) diff --git a/tenable/ie/session.py b/tenable/ie/session.py index 1161c45a6..cc2d6cc84 100644 --- a/tenable/ie/session.py +++ b/tenable/ie/session.py @@ -1,8 +1,6 @@ ''' Tenable Identity Exposure session ''' -import warnings -import os from tenable.base.platform import APIPlatform diff --git a/tenable/ie/users/api.py b/tenable/ie/users/api.py index 3f480332e..4b89972eb 100644 --- a/tenable/ie/users/api.py +++ b/tenable/ie/users/api.py @@ -10,7 +10,7 @@ :members: ''' from typing import List, Dict -from restfly.utils import dict_merge +from tenable.utils import dict_merge from tenable.base.endpoint import APIEndpoint from .schema import UserSchema, UserInfoSchema diff --git a/tenable/io/__init__.py b/tenable/io/__init__.py index fd7fabbbd..722a75b1e 100644 --- a/tenable/io/__init__.py +++ b/tenable/io/__init__.py @@ -80,7 +80,6 @@ from .sync.api import SynchronizationAPI from .tags import TagsAPI from .users import UsersAPI -from .v3 import Version3API from .was.api import WasAPI from .workbenches import WorkbenchesAPI @@ -435,18 +434,6 @@ def workbenches(self): ) return WorkbenchesAPI(self) - @property - def v3(self): - warnings.warn( - 'The V3 sub-pkg have been deprecated from the TVM ' - 'package. This method will be removed in a future ' - 'version of the SDK. Please use the relocated modules ' - 'within the package', - DeprecationWarning, - stacklevel=2, - ) - return Version3API(self) - @property def was(self): """ diff --git a/tenable/io/access_control.py b/tenable/io/access_control.py index 781dcf486..ef7de2beb 100644 --- a/tenable/io/access_control.py +++ b/tenable/io/access_control.py @@ -11,9 +11,12 @@ .. autoclass:: AccessControlAPI :members: """ -from uuid import UUID + from typing import Dict, List +from uuid import UUID + from tenable.io.base import TIOEndpoint +from tenable.utils import scrub class AccessControlAPI(TIOEndpoint): @@ -39,7 +42,7 @@ def details(self, uuid: UUID) -> Dict: ... '4f948c22-ae2c-4d0b-bab4-0fc1088a85bd' ... ) """ - return self._get(f'permissions/{uuid}') + return self._get(f'permissions/{scrub(uuid)}') def get_user_permission(self, user_uuid: UUID) -> Dict: """ @@ -60,7 +63,7 @@ def get_user_permission(self, user_uuid: UUID) -> Dict: ... '4f948c22-ae2c-4d0b-bab4-0fc1088a85bd' ... ) """ - return self._get(f'permissions/users/{user_uuid}') + return self._get(f'permissions/users/{scrub(user_uuid)}') def get_user_group_permission(self, user_group_uuid: UUID) -> Dict: """ @@ -82,7 +85,7 @@ def get_user_group_permission(self, user_group_uuid: UUID) -> Dict: ... '4f948c22-ae2c-4d0b-bab4-0fc1088a85bd' ... ) """ - return self._get(f'permissions/user-groups/{user_group_uuid}') + return self._get(f'permissions/user-groups/{scrub(user_group_uuid)}') def get_current_user_permission(self) -> Dict: """ @@ -122,7 +125,7 @@ def delete(self, permission_uuid: UUID) -> Dict: ... '4f948c22-ae2c-4d0b-bab4-0fc1088a85bd' ... ) """ - return self._delete(f'permissions/{permission_uuid}') + return self._delete(f'permissions/{scrub(permission_uuid)}') def create(self, permission: Dict) -> Dict: """ @@ -200,7 +203,7 @@ def update(self, permission_uuid: UUID, permission: Dict) -> Dict: >>> permission_uuid_val = "212-ae2c-4d0b-bab4-0fc1088a85bd" >>> tio.v3.access_control.update(permission_uuid_val, payload) """ - self._put(f'permissions/{permission_uuid}', json=permission) + self._put(f'permissions/{scrub(permission_uuid)}', json=permission) def list(self) -> List: """ diff --git a/tenable/io/agent_config.py b/tenable/io/agent_config.py index bcf8f2fe6..136a34773 100644 --- a/tenable/io/agent_config.py +++ b/tenable/io/agent_config.py @@ -1,4 +1,4 @@ -''' +""" Agent Config ============ @@ -10,15 +10,19 @@ .. rst-class:: hide-signature .. autoclass:: AgentConfigAPI :members: -''' +""" + from tenable.io.base import TIOEndpoint +from tenable.utils import scrub + class AgentConfigAPI(TIOEndpoint): - ''' + """ This will contain all methods related to agent config - ''' + """ + def edit(self, scanner_id=1, software_update=None, auto_unlink=None): - ''' + """ Edits the agent configuration. :devportal:`agent-config: edit ` @@ -52,7 +56,7 @@ def edit(self, scanner_id=1, software_update=None, auto_unlink=None): Enabling software updates for agents: >>> tio.agent_config.edit(software_update=True) - ''' + """ # Lets build the dictionary that we will present to the API... payload = {'auto_unlink': {}} if not scanner_id: @@ -62,18 +66,18 @@ def edit(self, scanner_id=1, software_update=None, auto_unlink=None): if auto_unlink: payload['auto_unlink']['enabled'] = True payload['auto_unlink']['expiration'] = self._check( - 'auto_unlink', auto_unlink, int, [False] + list(range(1, 366))) + 'auto_unlink', auto_unlink, int, [False] + list(range(1, 366)) + ) elif auto_unlink in [False, 0]: payload['auto_unlink']['enabled'] = False # Now to run the API call and get the response return self._api.put( - 'scanners/{}/agents/config'.format( - self._check('scanner_id', scanner_id, int) - ), json=payload).json() + f'scanners/{scrub(scanner_id)}/agents/config', json=payload + ).json() def details(self, scanner_id=1): - ''' + """ Returns the current agent configuration. :devportal:`agent-config: details ` @@ -88,10 +92,7 @@ def details(self, scanner_id=1): Examples: >>> details = tio.agent_config.details() >>> pprint(details) - ''' + """ if not scanner_id: scanner_id = 1 - return self._api.get( - 'scanners/{}/agents/config'.format( - self._check('scanner_id', scanner_id, int) - )).json() + return self._api.get(f'scanners/{scrub(scanner_id)}/agents/config').json() diff --git a/tenable/io/agent_exclusions.py b/tenable/io/agent_exclusions.py index b82621677..4b18e3cac 100644 --- a/tenable/io/agent_exclusions.py +++ b/tenable/io/agent_exclusions.py @@ -1,4 +1,4 @@ -''' +""" Agent Exclusions ================ @@ -10,17 +10,31 @@ .. rst-class:: hide-signature .. autoclass:: AgentExclusionsAPI :members: -''' -from restfly.utils import dict_merge, dict_clean +""" + +from datetime import datetime, timedelta + +from tenable.utils import dict_clean, dict_merge, scrub + from .base import TIOEndpoint -from datetime import date, datetime, timedelta + class AgentExclusionsAPI(TIOEndpoint): - def create(self, name, scanner_id=1, start_time=None, end_time=None, - timezone=None, description=None, frequency=None, - interval=None, weekdays=None, day_of_month=None, - enabled=True): - ''' + def create( + self, + name, + scanner_id=1, + start_time=None, + end_time=None, + timezone=None, + description=None, + frequency=None, + interval=None, + weekdays=None, + day_of_month=None, + enabled=True, + ): + """ Creates a new agent exclusion. :devportal:`agent-exclusions: create ` @@ -105,27 +119,36 @@ def create(self, name, scanner_id=1, start_time=None, end_time=None, ... frequency='yearly', ... start_time=datetime.utcnow(), ... end_time=datetime.utcnow() + timedelta(hours=1)) - ''' + """ # Starting with the innermost part of the payload, lets construct the # rrules dictionary. - frequency = self._check('frequency', frequency, str, + frequency = self._check( + 'frequency', + frequency, + str, choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], default='ONETIME', - case='upper') + case='upper', + ) rrules = { 'freq': frequency, - 'interval': self._check('interval', interval, int, default=1) + 'interval': self._check('interval', interval, int, default=1), } # if the frequency is a weekly one, then we will need to specify the # days of the week that the exclusion is run on. if frequency == 'WEEKLY': - rrules['byweekday'] = ','.join(self._check( - 'weekdays', weekdays, list, - choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - default=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - case='upper')) + rrules['byweekday'] = ','.join( + self._check( + 'weekdays', + weekdays, + list, + choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + default=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + case='upper', + ) + ) # In the same vein as the frequency check, we're accepting # case-insensitive input, comparing it to our known list of # acceptable responses, then joining them all together into a @@ -134,9 +157,13 @@ def create(self, name, scanner_id=1, start_time=None, end_time=None, # if the frequency is monthly, then we will need to specify the day of # the month that the rule will run on. if frequency == 'MONTHLY': - rrules['bymonthday'] = self._check('day_of_month', day_of_month, int, - choices=list(range(1,32)), - default=datetime.today().day) + rrules['bymonthday'] = self._check( + 'day_of_month', + day_of_month, + int, + choices=list(range(1, 32)), + default=datetime.today().day, + ) # Next we need to construct the rest of the payload payload = { @@ -144,15 +171,23 @@ def create(self, name, scanner_id=1, start_time=None, end_time=None, 'description': self._check('description', description, str, default=''), 'schedule': { 'enabled': self._check('enabled', enabled, bool, default=True), - 'starttime': self._check('start_time', start_time, datetime).strftime('%Y-%m-%d %H:%M:%S') - if enabled is True else datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'), - 'endtime': self._check('end_time', end_time, datetime).strftime('%Y-%m-%d %H:%M:%S') - if enabled is True else (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S'), - 'timezone': self._check('timezone', timezone, str, - choices=self._api._tz, - default='Etc/UTC'), - 'rrules': rrules - } + 'starttime': self._check('start_time', start_time, datetime).strftime( + '%Y-%m-%d %H:%M:%S' + ) + if enabled is True + else datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'), + 'endtime': self._check('end_time', end_time, datetime).strftime( + '%Y-%m-%d %H:%M:%S' + ) + if enabled is True + else (datetime.utcnow() + timedelta(hours=1)).strftime( + '%Y-%m-%d %H:%M:%S' + ), + 'timezone': self._check( + 'timezone', timezone, str, choices=self._api._tz, default='Etc/UTC' + ), + 'rrules': rrules, + }, } # Lets check to make sure that the scanner_id is an integer as the API @@ -160,11 +195,13 @@ def create(self, name, scanner_id=1, start_time=None, end_time=None, # the call. return self._api.post( 'scanners/{}/agents/exclusions'.format( - self._check('scanner_id', scanner_id, int) - ), json=payload).json() + scrub(self._check('scanner_id', scanner_id, int)) + ), + json=payload, + ).json() def delete(self, exclusion_id, scanner_id=1): - ''' + """ Delete an agent exclusion. :devportal:`agent-exclusions: delete ` @@ -178,14 +215,16 @@ def delete(self, exclusion_id, scanner_id=1): Examples: >>> tio.agent_exclusions.delete(1) - ''' - self._api.delete('scanners/{}/agents/exclusions/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('exclusion_id', exclusion_id, int) - )) + """ + self._api.delete( + 'scanners/{}/agents/exclusions/{}'.format( + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('exclusion_id', exclusion_id, int)), + ) + ) def details(self, exclusion_id, scanner_id=1): - ''' + """ Retrieve the details for a specific agent exclusion. :devportal:`agent-exclusion: details ` @@ -199,17 +238,30 @@ def details(self, exclusion_id, scanner_id=1): Examples: >>> exclusion = tio.agent_exclusions.details(1) - ''' + """ return self._api.get( 'scanners/{}/agents/exclusions/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('exclusion_id', exclusion_id, int) - )).json() - - def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, - end_time=None, timezone=None, description=None, frequency=None, - interval=None, weekdays=None, day_of_month=None, enabled=None): - ''' + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('exclusion_id', exclusion_id, int)), + ) + ).json() + + def edit( + self, + exclusion_id, + scanner_id=1, + name=None, + start_time=None, + end_time=None, + timezone=None, + description=None, + frequency=None, + interval=None, + weekdays=None, + day_of_month=None, + enabled=None, + ): + """ Edit an existing agent exclusion. :devportal:`agent-exclusions: edit ` @@ -248,7 +300,7 @@ def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, Examples: >>> exclusion = tio.agent_exclusions.edit(1, name='New Name') - ''' + """ # Lets start constructing the payload to be sent to the API... payload = self.details(exclusion_id, scanner_id=scanner_id) @@ -263,10 +315,14 @@ def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, payload['schedule']['enabled'] = self._check('enabled', enabled, bool) if payload['schedule']['enabled']: - frequency = self._check('frequency', frequency, str, - choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], - default=payload['schedule']['rrules']['freq'], - case='upper') + frequency = self._check( + 'frequency', + frequency, + str, + choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], + default=payload['schedule']['rrules']['freq'], + case='upper', + ) rrules = { 'freq': frequency, @@ -282,12 +338,19 @@ def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, # and byweekday/bymonthday key not already exist, assign default values # - if schedule rrules is not None and defined in edit params, assign new values if frequency == 'WEEKLY': - rrules['byweekday'] = ','.join(self._check( - 'weekdays', weekdays, list, - choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - default=payload['schedule']['rrules'].get('byweekday', '').split() - or ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - case='upper')) + rrules['byweekday'] = ','.join( + self._check( + 'weekdays', + weekdays, + list, + choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + default=payload['schedule']['rrules'] + .get('byweekday', '') + .split() + or ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + case='upper', + ) + ) # In the same vein as the frequency check, we're accepting # case-insensitive input, comparing it to our known list of # acceptable responses, then joining them all together into a @@ -295,8 +358,14 @@ def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, if frequency == 'MONTHLY': rrules['bymonthday'] = self._check( - 'day_of_month', day_of_month, int, choices=list(range(1, 32)), - default=payload['schedule']['rrules'].get('bymonthday', datetime.today().day)) + 'day_of_month', + day_of_month, + int, + choices=list(range(1, 32)), + default=payload['schedule']['rrules'].get( + 'bymonthday', datetime.today().day + ), + ) # update new rrules in existing payload dict_merge(payload['schedule']['rrules'], rrules) @@ -305,31 +374,37 @@ def edit(self, exclusion_id, scanner_id=1, name=None, start_time=None, if start_time: payload['schedule']['starttime'] = self._check( - 'start_time', start_time, datetime).strftime('%Y-%m-%d %H:%M:%S') + 'start_time', start_time, datetime + ).strftime('%Y-%m-%d %H:%M:%S') if end_time: payload['schedule']['endtime'] = self._check( - 'end_time', end_time, datetime).strftime('%Y-%m-%d %H:%M:%S') + 'end_time', end_time, datetime + ).strftime('%Y-%m-%d %H:%M:%S') if interval: payload['schedule']['rrules']['interval'] = self._check( - 'interval', interval, int) + 'interval', interval, int + ) if timezone: payload['schedule']['timezone'] = self._check( - 'timezone', timezone, str, choices=self._api._tz) + 'timezone', timezone, str, choices=self._api._tz + ) # Lets check to make sure that the scanner_id and exclusion_id are # integers as the API documentation requests and if we don't raise an # error, then lets make the call. return self._api.put( 'scanners/{}/agents/exclusions/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('exclusion_id', exclusion_id, int) - ), json=payload).json() + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('exclusion_id', exclusion_id, int)), + ), + json=payload, + ).json() def list(self, scanner_id=1): - ''' + """ Lists all of the currently configured agent exclusions. :devportal:`agent-exclusions: list ` @@ -343,8 +418,9 @@ def list(self, scanner_id=1): Examples: >>> for exclusion in tio.agent_exclusions.list(): ... pprint(exclusion) - ''' + """ return self._api.get( 'scanners/{}/agents/exclusions'.format( - self._check('scanner_id', scanner_id, int) - )).json()['exclusions'] + scrub(self._check('scanner_id', scanner_id, int)) + ) + ).json()['exclusions'] diff --git a/tenable/io/agent_groups.py b/tenable/io/agent_groups.py index fe921ab8e..a17553930 100644 --- a/tenable/io/agent_groups.py +++ b/tenable/io/agent_groups.py @@ -1,4 +1,4 @@ -''' +""" Agent Groups ============ @@ -10,13 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: AgentGroupsAPI :members: -''' +""" + +from tenable.utils import scrub + from .base import TIOEndpoint class AgentGroupsAPI(TIOEndpoint): def add_agent(self, group_id, *agent_ids, **kw): - ''' + """ Adds an agent or multiple agents to the agent group specified. :devportal:`agent-groups: add-agent ` @@ -44,7 +47,7 @@ def add_agent(self, group_id, *agent_ids, **kw): Adding multiple agents by uuid: >>> tio.agent_groups.add_agent(1, 'uuid-1', 'uuid-2', 'uuid-3') - ''' + """ scanner_id = 1 if 'scanner_id' in kw: scanner_id = kw['scanner_id'] @@ -54,22 +57,35 @@ def add_agent(self, group_id, *agent_ids, **kw): if len(agent_ids) <= 1: # if there is only 1 agent id, we will perform a singular add. - self._api.put('scanners/{}/agent-groups/{}/agents/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int), - self._check('agent_id', agent_ids[0], 'uuid' if useUuids else int) - )) + self._api.put( + 'scanners/{}/agent-groups/{}/agents/{}'.format( + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + scrub( + self._check( + 'agent_id', agent_ids[0], 'uuid' if useUuids else int + ) + ), + ) + ) else: # If there are many agent_ids, then we will want to perform a bulk # operation. return self._api.post( 'scanners/{}/agent-groups/{}/agents/_bulk/add'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int)), - json={'items': [self._check('agent_id', i, 'uuid' if useUuids else int) for i in agent_ids]}).json() + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + ), + json={ + 'items': [ + self._check('agent_id', i, 'uuid' if useUuids else int) + for i in agent_ids + ] + }, + ).json() def configure(self, group_id, name, scanner_id=1): - ''' + """ Renames an existing agent group. :devportal:`agent-groups: configure ` @@ -84,14 +100,17 @@ def configure(self, group_id, name, scanner_id=1): Examples: >>> tio.agent_groups.configure(1, 'New Name') - ''' - self._api.put('scanners/{}/agent-groups/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int) - ), json={'name': self._check('name', name, str)}).json() + """ + self._api.put( + 'scanners/{}/agent-groups/{}'.format( + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + ), + json={'name': self._check('name', name, str)}, + ).json() def create(self, name, scanner_id=1): - ''' + """ Creates a new agent group. :devportal:`agent-groups: create ` @@ -107,14 +126,16 @@ def create(self, name, scanner_id=1): Examples: >>> group = tio.agent_groups.create('New Agent Group') - ''' + """ return self._api.post( 'scanners/{}/agent-groups'.format( - self._check('scanner_id', scanner_id, int) - ), json={'name': self._check('name', name, str)}).json() + scrub(self._check('scanner_id', scanner_id, int)) + ), + json={'name': self._check('name', name, str)}, + ).json() def delete(self, group_id, scanner_id=1): - ''' + """ Delete an agent group. :devportal:`agent-groups: delete ` @@ -128,14 +149,16 @@ def delete(self, group_id, scanner_id=1): Examples: >>> tio.agent_groups.delete(1) - ''' - self._api.delete('scanners/{}/agent-groups/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int) - )) + """ + self._api.delete( + 'scanners/{}/agent-groups/{}'.format( + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + ) + ) def delete_agent(self, group_id, *agent_ids, **kw): - ''' + """ Delete one or many agents from an agent group. :devportal:`agent-groups: delete-agent ` @@ -159,29 +182,33 @@ def delete_agent(self, group_id, *agent_ids, **kw): Delete multiple agents from an agent group: >>> tio.agent_groups.delete_agent(1, 1, 2, 3) - ''' + """ scanner_id = 1 if 'scanner_id' in kw: scanner_id = kw['scanner_id'] if len(agent_ids) <= 1: # if only a singular agent_id was passed, then we will want to - self._api.delete('scanners/{}/agent-groups/{}/agents/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int), - self._check('agent_id', agent_ids[0], int) - )) + self._api.delete( + 'scanners/{}/agent-groups/{}/agents/{}'.format( + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + scrub(self._check('agent_id', agent_ids[0], int)), + ) + ) else: # if multiple agent ids were requested to be deleted, then we will # call the bulk deletion API. return self._api.post( 'scanners/{}/agent-groups/{}/agents/_bulk/remove'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int)), - json={'items': [self._check('agent_ids', i, int) for i in agent_ids]}).json() + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + ), + json={'items': [self._check('agent_ids', i, int) for i in agent_ids]}, + ).json() def details(self, group_id, *filters, **kw): - ''' + """ Retrieve the details about the specified agent group. :devportal:`agent-groups: details ` @@ -229,13 +256,11 @@ def details(self, group_id, *filters, **kw): Examples: >>> group = tio.agent_groups.details(1) >>> pprint(group) - ''' + """ scanner_id = 1 - limit = 50 - offset = 0 - pages = None - query = self._parse_filters(filters, - self._api.filters.agents_filters(), rtype='colon') + query = self._parse_filters( + filters, self._api.filters.agents_filters(), rtype='colon' + ) # Overload the scanner_id with a new value if it has been requested # to do so. @@ -254,16 +279,24 @@ def details(self, group_id, *filters, **kw): # sort=field1:asc,field2:desc # if 'sort' in kw and self._check('sort', kw['sort'], tuple): - query['sort'] = ','.join(['{}:{}'.format( - self._check('sort_field', i[0], str), - self._check('sort_direction', i[1], str, choices=['asc', 'desc']) - ) for i in kw['sort']]) + query['sort'] = ','.join( + [ + '{}:{}'.format( + self._check('sort_field', i[0], str), + self._check( + 'sort_direction', i[1], str, choices=['asc', 'desc'] + ), + ) + for i in kw['sort'] + ] + ) # The filter_type determines how the filters are combined together. # The default is 'and', however you can always explicitly define 'and' # or 'or'. if 'filter_type' in kw and self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']): + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ): query['ft'] = kw['filter_type'] # The wild-card filter text refers to how the API will pattern match @@ -274,7 +307,8 @@ def details(self, group_id, *filters, **kw): # The wildcard_fields parameter allows the user to restrict the fields # that the wild-card pattern match pertains to. if 'wildcard_fields' in kw and self._check( - 'wildcard_fields', kw['wildcard_fields'], list): + 'wildcard_fields', kw['wildcard_fields'], list + ): query['wf'] = ','.join(kw['wildcard_fields']) # If the offset was set to something other than the default starting @@ -290,13 +324,14 @@ def details(self, group_id, *filters, **kw): return self._api.get( 'scanners/{}/agent-groups/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int) - ), params=query + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + ), + params=query, ).json() def list(self, scanner_id=1): - ''' + """ Retrieves the list of agent groups configured :devportal:`agent-groups: list ` @@ -312,12 +347,15 @@ def list(self, scanner_id=1): >>>> for agent_group in tio.agent_groups.list(): ... pprint(agent_group) - ''' - return self._api.get('scanners/{}/agent-groups'.format( - self._check('scanner_id', scanner_id, int))).json()['groups'] + """ + return self._api.get( + 'scanners/{}/agent-groups'.format( + scrub(self._check('scanner_id', scanner_id, int)) + ) + ).json()['groups'] def task_status(self, group_id, task_uuid, scanner_id=1): - ''' + """ Retrieves the current status of a bulk task. :devportal:`bulk-operations: bulk-agent-group-status ` @@ -335,10 +373,11 @@ def task_status(self, group_id, task_uuid, scanner_id=1): >>> item = tio.agent_groups.add_agent(1, 21, 22, 23) >>> task = tio.agent_groups.task_status(item['task_uuid']) >>> pprint(task) - ''' + """ return self._api.get( 'scanners/{}/agent-groups/{}/agents/_bulk/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('group_id', group_id, int), - self._check('task_uuid', task_uuid, 'uuid') - )).json() + scrub(self._check('scanner_id', scanner_id, int)), + scrub(self._check('group_id', group_id, int)), + scrub(self._check('task_uuid', task_uuid, 'uuid')), + ) + ).json() diff --git a/tenable/io/agents.py b/tenable/io/agents.py index fc77b59a7..8bbfd2a9e 100644 --- a/tenable/io/agents.py +++ b/tenable/io/agents.py @@ -1,4 +1,4 @@ -''' +""" Agents ====== @@ -10,11 +10,15 @@ .. rst-class:: hide-signature .. autoclass:: AgentsAPI :members: -''' -from .base import TIOIterator, TIOEndpoint +""" + +from tenable.utils import scrub + +from .base import TIOEndpoint, TIOIterator + class AgentsIterator(TIOIterator): - ''' + """ The agents iterator provides a scalable way to work through agent result sets of any size. The iterator will walk through each page of data, returning one record at a time. If it reaches the end of a page of @@ -31,13 +35,14 @@ class AgentsIterator(TIOIterator): page_count (int): The number of record returned from the current page. total (int): The total number of records that exist for the current request. - ''' + """ + pass class AgentsAPI(TIOEndpoint): def list(self, *filters, **kw): - ''' + """ Get the listing of configured agents from Tenable Vulnerability Management. :devportal:`agents: list ` @@ -92,13 +97,14 @@ def list(self, *filters, **kw): >>> for agent in tio.agents.list(('distro', 'match', 'win')): ... pprint(agent) - ''' + """ scanner_id = 1 limit = 50 offset = 0 pages = None - query = self._parse_filters(filters, - self._api.filters.agents_filters(), rtype='colon') + query = self._parse_filters( + filters, self._api.filters.agents_filters(), rtype='colon' + ) # Overload the scanner_id with a new value if it has been requested # to do so. @@ -128,16 +134,24 @@ def list(self, *filters, **kw): # sort=field1:asc,field2:desc # if 'sort' in kw and self._check('sort', kw['sort'], tuple): - query['sort'] = ','.join(['{}:{}'.format( - self._check('sort_field', i[0], str), - self._check('sort_direction', i[1], str, choices=['asc', 'desc']) - ) for i in kw['sort']]) + query['sort'] = ','.join( + [ + '{}:{}'.format( + self._check('sort_field', i[0], str), + self._check( + 'sort_direction', i[1], str, choices=['asc', 'desc'] + ), + ) + for i in kw['sort'] + ] + ) # The filter_type determines how the filters are combined together. # The default is 'and', however you can always explicitly define 'and' # or 'or'. if 'filter_type' in kw and self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']): + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ): query['ft'] = kw['filter_type'] # The wild-card filter text refers to how the API will pattern match @@ -148,21 +162,23 @@ def list(self, *filters, **kw): # The wildcard_fields parameter allows the user to restrict the fields # that the wild-card pattern match pertains to. if 'wildcard_fields' in kw and self._check( - 'wildcard_fields', kw['wildcard_fields'], list): + 'wildcard_fields', kw['wildcard_fields'], list + ): query['wf'] = ','.join(kw['wildcard_fields']) # Return the Iterator. - return AgentsIterator(self._api, + return AgentsIterator( + self._api, _limit=limit, _offset=offset, _pages_total=pages, _query=query, - _path='scanners/{}/agents'.format(scanner_id), - _resource='agents' + _path=f'scanners/{scrub(scanner_id)}/agents', + _resource='agents', ) def details(self, agent_id, scanner_id=1): - ''' + """ Retrieves the details of an agent. :devportal:`agents: get ` @@ -180,15 +196,13 @@ def details(self, agent_id, scanner_id=1): Examples: >>> agent = tio.agents.details(1) >>> pprint(agent) - ''' + """ return self._api.get( - 'scanners/{}/agents/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('agent_id', agent_id, int) - )).json() + f'scanners/{scrub(scanner_id)}/agents/{scrub(agent_id)}' + ).json() def unlink(self, *agent_ids, **kw): - ''' + """ Unlink one or multiple agents from the Tenable Vulnerability Management instance. :devportal:`agents: delete ` @@ -213,7 +227,7 @@ def unlink(self, *agent_ids, **kw): Unlink many agents: >>> tio.agents.unlink(1, 2, 3) - ''' + """ scanner_id = 1 if 'scanner_id' in kw: scanner_id = kw['scanner_id'] @@ -221,17 +235,17 @@ def unlink(self, *agent_ids, **kw): if len(agent_ids) <= 1: # as only a singular agent_id was sent over, we can call the delete # API - self._api.delete('scanners/{}/agents/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('agent_id', agent_ids[0], int) - )) + self._api.delete( + f'scanners/{scrub(scanner_id)}/agents/{scrub(agent_ids[0])}' + ) else: - return self._api.post('scanners/{}/agents/_bulk/unlink'.format( - self._check('scanner_id', scanner_id, int)), - json={'items': [self._check('agent_ids', i, int) for i in agent_ids]}).json() + return self._api.post( + f'scanners/{scrub(scanner_id)}/agents/_bulk/unlink', + json={'items': [self._check('agent_ids', i, int) for i in agent_ids]}, + ).json() def task_status(self, task_uuid, scanner_id=1): - ''' + """ Retrieves the current status of the task requested. :devportal:`bulk-operations: bulk-agent-status ` @@ -248,9 +262,7 @@ def task_status(self, task_uuid, scanner_id=1): >>> item = tio.agents.unlink(21, 22, 23) >>> task = tio.agent.task_status(item['task_uuid']) >>> pprint(task) - ''' + """ return self._api.get( - 'scanners/{}/agents/_bulk/{}'.format( - self._check('scanner_id', scanner_id, int), - self._check('task_uuid', task_uuid, 'uuid') - )).json() + f'scanners/{scrub(scanner_id)}/agents/_bulk/{scrub(task_uuid)}' + ).json() diff --git a/tenable/io/assets.py b/tenable/io/assets.py index 2f10c3a34..b7f694eb9 100644 --- a/tenable/io/assets.py +++ b/tenable/io/assets.py @@ -13,6 +13,7 @@ """ from tenable.io.base import TIOEndpoint +from tenable.utils import scrub class AssetsAPI(TIOEndpoint): @@ -34,7 +35,7 @@ def list(self): >>> for asset in tio.assets.list(): ... pprint(asset) """ - return self._api.get("assets").json()["assets"] + return self._api.get('assets').json()['assets'] def delete(self, *uuid): """ @@ -55,7 +56,7 @@ def delete(self, *uuid): >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> tio.asset.delete(asset_id) """ - self.bulk_delete(*[("host.id", "eq", str(i)) for i in uuid], filter_type="or") + self.bulk_delete(*[('host.id', 'eq', str(i)) for i in uuid], filter_type='or') def details(self, uuid): """ @@ -75,7 +76,7 @@ def details(self, uuid): >>> asset = tio.assets.details( ... '00000000-0000-0000-0000-000000000000') """ - return self._api.get("assets/{}".format(self._check("uuid", uuid, str))).json() + return self._api.get(f'assets/{scrub(uuid)}').json() def assign_tags(self, action, assets, tags): """ @@ -101,11 +102,11 @@ def assign_tags(self, action, assets, tags): ... ['00000000-0000-0000-0000-000000000000']) """ return self._api.post( - "tags/assets/assignments", + 'tags/assets/assignments', json={ - "action": self._check("action", action, str, choices=["add", "remove"]), - "assets": [self._check("asset", i, "uuid") for i in assets], - "tags": [self._check("source", i, "uuid") for i in tags], + 'action': self._check('action', action, str, choices=['add', 'remove']), + 'assets': [self._check('asset', i, 'uuid') for i in assets], + 'tags': [self._check('source', i, 'uuid') for i in tags], }, ).json() @@ -127,9 +128,7 @@ def tags(self, uuid): >>> asset = tio.assets.tags( ... '00000000-0000-0000-0000-000000000000') """ - return self._api.get( - "tags/assets/{}/assignments".format(self._check("uuid", uuid, "uuid")) - ).json() + return self._api.get(f'tags/assets/{scrub(uuid)}/assignments').json() def asset_import(self, source, *assets): """ @@ -181,12 +180,12 @@ def asset_import(self, source, *assets): # asset resources that are being defined, however a simple type check # should suffice for now. return self._api.post( - "import/assets", + 'import/assets', json={ - "assets": [self._check("asset", i, dict) for i in assets], - "source": self._check("source", source, str), + 'assets': [self._check('asset', i, dict) for i in assets], + 'source': self._check('source', source, str), }, - ).json()["asset_import_job_uuid"] + ).json()['asset_import_job_uuid'] def list_import_jobs(self): """ @@ -202,7 +201,7 @@ def list_import_jobs(self): >>> for job in tio.assets.list_import_jobs(): ... pprint(job) """ - return self._api.get("import/asset-jobs").json()["asset_import_jobs"] + return self._api.get('import/asset-jobs').json()['asset_import_jobs'] def import_job_details(self, uuid): """ @@ -222,9 +221,7 @@ def import_job_details(self, uuid): ... '00000000-0000-0000-0000-000000000000') >>> pprint(job) """ - return self._api.get( - "import/asset-jobs/{}".format(self._check("uuid", uuid, str)) - ).json() + return self._api.get(f'import/asset-jobs/{scrub(uuid)}').json() def move_assets(self, source, destination, targets): """ @@ -249,13 +246,13 @@ def move_assets(self, source, destination, targets): >>> pprint(asset) """ payload = { - "source": self._check("source", source, "uuid"), - "destination": self._check("destination", destination, "uuid"), - "targets": ",".join(self._check("targets", targets, list)), + 'source': self._check('source', source, 'uuid'), + 'destination': self._check('destination', destination, 'uuid'), + 'targets': ','.join(self._check('targets', targets, list)), } return self._api.post( - "api/v2/assets/bulk-jobs/move-to-network", json=payload + 'api/v2/assets/bulk-jobs/move-to-network', json=payload ).json() def bulk_delete(self, *filters, hard_delete=None, filter_type=None): @@ -290,24 +287,24 @@ def bulk_delete(self, *filters, hard_delete=None, filter_type=None): # run the rules through the filter parser... filter_type = self._check( - "filter_type", + 'filter_type', filter_type, str, - choices=["and", "or"], - default="and", - case="lower", + choices=['and', 'or'], + default='and', + case='lower', ) parsed = self._parse_filters( - filters, self._api.filters.workbench_asset_filters(), rtype="assets" - )["asset"] + filters, self._api.filters.workbench_asset_filters(), rtype='assets' + )['asset'] if hard_delete: - payload["hard_delete"] = self._check("hard_delete", hard_delete, bool) - payload["query"] = {filter_type: parsed} + payload['hard_delete'] = self._check('hard_delete', hard_delete, bool) + payload['query'] = {filter_type: parsed} - return self._api.post("api/v2/assets/bulk-jobs/delete", json=payload).json() + return self._api.post('api/v2/assets/bulk-jobs/delete', json=payload).json() - def update_acr(self, assets_uuid_list, reason, value, note=""): + def update_acr(self, assets_uuid_list, reason, value, note=''): """ Updates ACR for the provided asset UUID's with reason(s). @@ -333,16 +330,16 @@ def update_acr(self, assets_uuid_list, reason, value, note=""): asset_uuids = [] for asset_uuid in assets_uuid_list: - asset_uuids.append({"id": asset_uuid}) + asset_uuids.append({'id': asset_uuid}) - note = note + " - pyTenable" + note = note + ' - pyTenable' payload = [ { - "acr_score": int(value), - "reason": reason, - "asset": asset_uuids, - "note": note, + 'acr_score': int(value), + 'reason': reason, + 'asset': asset_uuids, + 'note': note, } ] - return self._api.post("api/v2/assets/bulk-jobs/acr", json=payload).status_code + return self._api.post('api/v2/assets/bulk-jobs/acr', json=payload).status_code diff --git a/tenable/io/audit_log.py b/tenable/io/audit_log.py index e162f8720..c4e49620c 100644 --- a/tenable/io/audit_log.py +++ b/tenable/io/audit_log.py @@ -15,7 +15,7 @@ from typing import Tuple, Optional, Dict from copy import copy from .base import TIOEndpoint -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator class AuditLogIterator(APIIterator): diff --git a/tenable/io/base/__init__.py b/tenable/io/base/__init__.py index e7e2ac4bb..cba11de38 100644 --- a/tenable/io/base/__init__.py +++ b/tenable/io/base/__init__.py @@ -1 +1 @@ -from .v1 import * +from .v1 import * # noqa: F403 diff --git a/tenable/io/credentials.py b/tenable/io/credentials.py index 6d1bed53b..df67dd418 100644 --- a/tenable/io/credentials.py +++ b/tenable/io/credentials.py @@ -1,4 +1,4 @@ -''' +""" Credentials =========== @@ -10,14 +10,17 @@ .. rst-class:: hide-signature .. autoclass:: CredentialsAPI :members: -''' +""" + from typing import BinaryIO -from tenable.utils import dict_merge +from tenable.utils import dict_merge, scrub + from .base import TIOEndpoint, TIOIterator + class CredentialsIterator(TIOIterator): - ''' + """ The credentials iterator provides a scalable way to work through networks result sets of any size. The iterator will walk through each page of data, returning one record at a time. If it reaches the end of a page of records, @@ -34,15 +37,17 @@ class CredentialsIterator(TIOIterator): page_count (int): The number of record returned from the current page. total (int): The total number of records that exist for the current request. - ''' + """ + pass + class CredentialsAPI(TIOEndpoint): def _permissions_constructor(self, permissions): - ''' + """ Validates and/or transforms thew permissions items into the desired format. If a dict it will validate. If a tuple, will convert. - ''' + """ resp = list() for p in permissions: if isinstance(p, tuple): @@ -54,32 +59,43 @@ def _permissions_constructor(self, permissions): 'use': 32, 'edit': 64, } - resp.append({ - 'type': self._check('permission:type', p[0], str, - choices=['user', 'group']), - 'permissions': ptnx[self._check('permissions:permission', - p[1], (str, int), choices=[32, 64, 'use', 'edit'])], - 'grantee_uuid': self._check('permission:uuid', p[2], 'uuid') - }) - - elif isinstance(p, dict) : + resp.append( + { + 'type': self._check( + 'permission:type', p[0], str, choices=['user', 'group'] + ), + 'permissions': ptnx[ + self._check( + 'permissions:permission', + p[1], + (str, int), + choices=[32, 64, 'use', 'edit'], + ) + ], + 'grantee_uuid': self._check('permission:uuid', p[2], 'uuid'), + } + ) + + elif isinstance(p, dict): # if the item is a dictionary, validate it and then pass into # the response list. - self._check('permission:type', p['type'], str, - choices=['user', 'group']) - self._check('permission:permissions', p['permissions'], int, - choices=[32, 64]) + self._check( + 'permission:type', p['type'], str, choices=['user', 'group'] + ) + self._check( + 'permission:permissions', p['permissions'], int, choices=[32, 64] + ) self._check('permission:grantee_uuid', p['grantee_uuid'], 'uuid') resp.append(p) - else : + else: raise TypeError('permission object is not tuple or dict type') return resp - - def create(self, cred_name, cred_type, description=None, - permissions=None, **settings): - ''' + def create( + self, cred_name, cred_type, description=None, permissions=None, **settings + ): + """ Creates a new managed credential. :devportal:`credentials: create ` @@ -124,21 +140,31 @@ def create(self, cred_name, cred_type, description=None, ... elevate_privileges_with='sudo', ... bin_directory='/usr/bin', ... custom_password_prompt='') - ''' + """ if not permissions: permissions = list() - return self._api.post('credentials', json={ - 'name': self._check('cred_name', cred_name, str), - 'description': self._check('description', description, str, default=''), - 'type': self._check('cred_type', cred_type, str), - 'settings': settings, - 'permissions': self._permissions_constructor(permissions) - }).json()['uuid'] - - def edit(self, cred_uuid, cred_name=None, description=None, - permissions=None, ad_hoc=None, **settings): - ''' + return self._api.post( + 'credentials', + json={ + 'name': self._check('cred_name', cred_name, str), + 'description': self._check('description', description, str, default=''), + 'type': self._check('cred_type', cred_type, str), + 'settings': settings, + 'permissions': self._permissions_constructor(permissions), + }, + ).json()['uuid'] + + def edit( + self, + cred_uuid, + cred_name=None, + description=None, + permissions=None, + ad_hoc=None, + **settings, + ): + """ Creates a new managed credential. :devportal:`credentials: create ` @@ -176,26 +202,26 @@ def edit(self, cred_uuid, cred_name=None, description=None, >>> tio.credentials.edit(cred_uuid, ... password='sekretsquirrel', ... escalation_password='sudopassword') - ''' + """ current = self.details(cred_uuid) payload = { - 'name': self._check('cred_name', cred_name, str, - default=current['name']), - 'description': self._check('description', description, str, - default=current['description']), - 'ad_hoc': self._check('ad_hoc', ad_hoc, bool, - default=current['ad_hoc']), + 'name': self._check('cred_name', cred_name, str, default=current['name']), + 'description': self._check( + 'description', description, str, default=current['description'] + ), + 'ad_hoc': self._check('ad_hoc', ad_hoc, bool, default=current['ad_hoc']), } if permissions: payload['permissions'] = self._permissions_constructor(permissions) payload['settings'] = dict_merge(current['settings'], settings) - return self._api.put('credentials/{}'.format(cred_uuid), - json=payload).json()['updated'] + return self._api.put(f'credentials/{scrub(cred_uuid)}', json=payload).json()[ + 'updated' + ] def details(self, id): - ''' + """ Retrieves the details of the specified credential. :devportal:`credentials: details ` @@ -210,12 +236,11 @@ def details(self, id): Examples: >>> cred_uuid = '00000000-0000-0000-0000-000000000000' >>> cred = tio.credentials.details(cred_uuid) - ''' - return self._api.get('credentials/{}'.format( - self._check('id', id, 'uuid'))).json() + """ + return self._api.get(f'credentials/{scrub(id)}').json() def delete(self, id): - ''' + """ Deletes the specified credential. :devportal:`credentials: delete ` @@ -230,12 +255,11 @@ def delete(self, id): Examples: >>> cred_uuid = '00000000-0000-0000-0000-000000000000' >>> cred = tio.credentials.delete(cred_uuid) - ''' - return self._api.delete('credentials/{}'.format( - self._check('id', id, 'uuid'))).json()['deleted'] + """ + return self._api.delete(f'credentials/{scrub(id)}').json()['deleted'] def types(self): - ''' + """ Lists all of the available credential types. :devportal:`credentials: list-types ` @@ -246,11 +270,11 @@ def types(self): Examples: >>> cred_types = tio.credentials.types() - ''' + """ return self._api.get('credentials/types').json()['credentials'] def list(self, *filters, **kw): - ''' + """ Get the listing of configured credentials from Tenable Vulnerability Management. :devportal:`credentials: list ` @@ -299,12 +323,13 @@ def list(self, *filters, **kw): Examples: >>> for cred in tio.credentials.list(): ... pprint(cred) - ''' + """ limit = 50 offset = 0 pages = None - query = self._parse_filters(filters, - self._api.filters.networks_filters(), rtype='colon') + query = self._parse_filters( + filters, self._api.filters.networks_filters(), rtype='colon' + ) # If a referrer owner uuid is passed, then add it to the query. if 'owner_uuid' in kw and self._check('owner_uuid', kw['owner_uuid'], 'uuid'): @@ -333,16 +358,24 @@ def list(self, *filters, **kw): # sort=field1:asc,field2:desc # if 'sort' in kw and self._check('sort', kw['sort'], tuple): - query['sort'] = ','.join(['{}:{}'.format( - self._check('sort_field', i[0], str), - self._check('sort_direction', i[1], str, choices=['asc', 'desc']) - ) for i in kw['sort']]) + query['sort'] = ','.join( + [ + '{}:{}'.format( + self._check('sort_field', i[0], str), + self._check( + 'sort_direction', i[1], str, choices=['asc', 'desc'] + ), + ) + for i in kw['sort'] + ] + ) # The filter_type determines how the filters are combined together. # The default is 'and', however you can always explicitly define 'and' # or 'or'. if 'filter_type' in kw and self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']): + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ): query['ft'] = kw['filter_type'] # The wild-card filter text refers to how the API will pattern match @@ -353,21 +386,23 @@ def list(self, *filters, **kw): # The wildcard_fields parameter allows the user to restrict the fields # that the wild-card pattern match pertains to. if 'wildcard_fields' in kw and self._check( - 'wildcard_fields', kw['wildcard_fields'], list): + 'wildcard_fields', kw['wildcard_fields'], list + ): query['wf'] = ','.join(kw['wildcard_fields']) # Return the Iterator. - return CredentialsIterator(self._api, + return CredentialsIterator( + self._api, _limit=limit, _offset=offset, _pages_total=pages, _query=query, _path='credentials', - _resource='credentials' + _resource='credentials', ) def upload(self, fobj: BinaryIO, file_type: str): - ''' + """ Uploads a file for use with a managed credential. :devportal:`credentials: upload ` @@ -387,16 +422,19 @@ def upload(self, fobj: BinaryIO, file_type: str): ... response = tio.credentials.upload(file, "pem") ... ... print(response) - ''' + """ # We will attempt to discover the name of the file stored within the # file object. If the name of the file is successfully discovered, we # will generate a random uuid string and append it to the name. # Otherwise, we will generate a random uuid string to use instead. - kw = { - 'files': { - 'Filedata': fobj - } - } - file_type = self._check("file_type", file_type, str, choices=["pem", "json", "csv", "x.509", "p12", "ssh", "cookie"]) - return self._api.post(f'credentials/files?fileType={file_type}', **kw).json()['fileuploaded'] + kw = {'files': {'Filedata': fobj}} + file_type = self._check( + 'file_type', + file_type, + str, + choices=['pem', 'json', 'csv', 'x.509', 'p12', 'ssh', 'cookie'], + ) + return self._api.post(f'credentials/files?fileType={file_type}', **kw).json()[ + 'fileuploaded' + ] diff --git a/tenable/io/cs/images.py b/tenable/io/cs/images.py index f6bf79a26..ed2946eb7 100644 --- a/tenable/io/cs/images.py +++ b/tenable/io/cs/images.py @@ -13,7 +13,7 @@ ''' from typing_extensions import Literal from typing import Optional, Dict, Union -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint from tenable.io.cs.iterator import CSIterator diff --git a/tenable/io/cs/iterator.py b/tenable/io/cs/iterator.py index e8c0fbc9c..5ee3e3364 100644 --- a/tenable/io/cs/iterator.py +++ b/tenable/io/cs/iterator.py @@ -1,7 +1,7 @@ ''' Container Security Iterator module. ''' -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator class CSIterator(APIIterator): diff --git a/tenable/io/cs/repositories.py b/tenable/io/cs/repositories.py index de001ea16..5223f7f70 100644 --- a/tenable/io/cs/repositories.py +++ b/tenable/io/cs/repositories.py @@ -13,7 +13,7 @@ :members: ''' from typing import Optional, Dict, Union -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint from tenable.io.cs.iterator import CSIterator diff --git a/tenable/io/editor.py b/tenable/io/editor.py index 11490f78f..a9cf78f5a 100644 --- a/tenable/io/editor.py +++ b/tenable/io/editor.py @@ -24,7 +24,7 @@ from io import BytesIO from typing import Any, Dict, List, Literal -from tenable.utils import dict_merge, policy_settings +from tenable.utils import dict_merge, policy_settings, scrub from .base import TIOEndpoint @@ -169,7 +169,7 @@ def audits( # Now we need to make the actual call. resp = self._api.get( - f'editor/{str(etype)}/{str(object_id)}/audits/{str(file_id)}', + f'editor/{scrub(etype)}/{scrub(object_id)}/audits/{scrub(file_id)}', stream=True, ) @@ -200,7 +200,7 @@ def template_details(self, etype, uuid): :obj:`dict`: Details on the requested template """ - return self._api.get(f'editor/{str(etype)}/templates/{str(uuid)}').json() + return self._api.get(f'editor/{scrub(etype)}/templates/{scrub(uuid)}').json() def obj_details(self, etype, id): """ @@ -219,7 +219,7 @@ def obj_details(self, etype, id): :obj:`dict`: Details of the requested object """ - return self._api.get(f'editor/{str(etype)}/{str(id)}').json() + return self._api.get(f'editor/{etype}/{scrub(id)}').json() def template_list(self, etype): """ @@ -236,7 +236,7 @@ def template_list(self, etype): :obj:`list`: Listing of template records. """ - return self._api.get(f'editor/{str(etype)}/templates').json()['templates'] + return self._api.get(f'editor/{scrub(etype)}/templates').json()['templates'] def plugin_description(self, policy_id, family_id, plugin_id): """ @@ -258,8 +258,8 @@ def plugin_description(self, policy_id, family_id, plugin_id): """ return self._api.get( ( - f'editor/policy/{str(policy_id)}/' - f'families/{str(family_id)}/plugins/{str(plugin_id)}' + f'editor/policy/{scrub(policy_id)}/' + f'families/{scrub(family_id)}/plugins/{scrub(plugin_id)}' ) ).json()['plugindescription'] @@ -350,7 +350,7 @@ def details(self, etype, id): # Clean out the empty attributes for templates: if etype == 'scan/policy': for key in list(obj['settings'].keys()): - if obj['settings'][key] == None: + if obj['settings'][key] is None: del obj['settings'][key] # return the scan document to the caller. diff --git a/tenable/io/exclusions.py b/tenable/io/exclusions.py index 02a87d4bb..f7342d8c7 100644 --- a/tenable/io/exclusions.py +++ b/tenable/io/exclusions.py @@ -1,4 +1,4 @@ -''' +""" Exclusions ========== @@ -10,21 +10,35 @@ .. rst-class:: hide-signature .. autoclass:: ExclusionsAPI :members: -''' +""" + from datetime import datetime -from restfly.utils import dict_merge + from tenable.io.base import TIOEndpoint +from tenable.utils import dict_merge, scrub + class ExclusionsAPI(TIOEndpoint): - ''' + """ This will contain all methods related to exclusions - ''' - - def create(self, name, members, start_time=None, end_time=None, - timezone=None, description=None, frequency=None, - interval=None, weekdays=None, day_of_month=None, - enabled=True, network_id=None): - ''' + """ + + def create( + self, + name, + members, + start_time=None, + end_time=None, + timezone=None, + description=None, + frequency=None, + interval=None, + weekdays=None, + day_of_month=None, + enabled=True, + network_id=None, + ): + """ Create a scan target exclusion. :devportal:`exclusions: create ` @@ -98,7 +112,7 @@ def create(self, name, members, start_time=None, end_time=None, ... start_time=datetime.utcnow(), ... end_time=datetime.utcnow() + timedelta(hours=1)) - Creating a monthly esxclusion: + Creating a monthly exclusion: >>> exclusion = tio.exclusions.create( ... 'Example Monthly Exclusion', @@ -116,27 +130,36 @@ def create(self, name, members, start_time=None, end_time=None, ... frequency='yearly', ... start_time=datetime.utcnow(), ... end_time=datetime.utcnow() + timedelta(hours=1)) - ''' + """ # Starting with the innermost part of the payload, lets construct the # rrules dictionary. - frequency = self._check('frequency', frequency, str, + frequency = self._check( + 'frequency', + frequency, + str, choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], default='ONETIME', - case='upper') + case='upper', + ) rrules = { 'freq': frequency, - 'interval': self._check('interval', interval, int, default=1) + 'interval': self._check('interval', interval, int, default=1), } # if the frequency is a weekly one, then we will need to specify the # days of the week that the exclusion is run on. if frequency == 'WEEKLY': - rrules['byweekday'] = ','.join(self._check( - 'weekdays', weekdays, list, - choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - default=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - case='upper')) + rrules['byweekday'] = ','.join( + self._check( + 'weekdays', + weekdays, + list, + choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + default=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + case='upper', + ) + ) # In the same vein as the frequency check, we're accepting # case-insensitive input, comparing it to our known list of # acceptable responses, then joining them all together into a @@ -145,21 +168,28 @@ def create(self, name, members, start_time=None, end_time=None, # if the frequency is monthly, then we will need to specify the day of # the month that the rule will run on. if frequency == 'MONTHLY': - rrules['bymonthday'] = self._check('day_of_month', day_of_month, int, - choices=list(range(1,32)), - default=datetime.today().day) + rrules['bymonthday'] = self._check( + 'day_of_month', + day_of_month, + int, + choices=list(range(1, 32)), + default=datetime.today().day, + ) # construct payload schedule based on enable if enabled is True: schedule = { 'enabled': True, - 'starttime': - self._check('start_time', start_time, datetime).strftime('%Y-%m-%d %H:%M:%S'), - 'endtime': - self._check('end_time', end_time, datetime).strftime('%Y-%m-%d %H:%M:%S'), - 'timezone': self._check('timezone', timezone, str, - choices=self._api._tz, default='Etc/UTC'), - 'rrules': rrules + 'starttime': self._check('start_time', start_time, datetime).strftime( + '%Y-%m-%d %H:%M:%S' + ), + 'endtime': self._check('end_time', end_time, datetime).strftime( + '%Y-%m-%d %H:%M:%S' + ), + 'timezone': self._check( + 'timezone', timezone, str, choices=self._api._tz, default='Etc/UTC' + ), + 'rrules': rrules, } elif enabled is False: schedule = {'enabled': False} @@ -171,16 +201,20 @@ def create(self, name, members, start_time=None, end_time=None, 'name': self._check('name', name, str), 'members': ','.join(self._check('members', members, list)), 'description': self._check('description', description, str, default=''), - 'network_id': self._check('network_id', network_id, 'uuid', - default='00000000-0000-0000-0000-000000000000'), - 'schedule': schedule + 'network_id': self._check( + 'network_id', + network_id, + 'uuid', + default='00000000-0000-0000-0000-000000000000', + ), + 'schedule': schedule, } # And now to make the call and return the data. return self._api.post('exclusions', json=payload).json() def delete(self, exclusion_id): - ''' + """ Delete a scan target exclusion. :devportal:`exclusions: delete ` @@ -194,11 +228,11 @@ def delete(self, exclusion_id): Examples: >>> tio.exclusions.delete(1) - ''' - self._api.delete('exclusions/{}'.format(self._check('exclusion_id', exclusion_id, int))) + """ + self._api.delete(f'exclusions/{scrub(exclusion_id)}') def details(self, exclusion_id): - ''' + """ Retrieve the details for a specific scan target exclusion. :devportal:`exclusions: details ` @@ -213,14 +247,26 @@ def details(self, exclusion_id): Examples: >>> exclusion = tio.exclusions.details(1) >>> pprint(exclusion) - ''' - return self._api.get( - 'exclusions/{}'.format(self._check('exclusion_id', exclusion_id, int))).json() - - def edit(self, exclusion_id, name=None, members=None, start_time=None, - end_time=None, timezone=None, description=None, frequency=None, - interval=None, weekdays=None, day_of_month=None, enabled=None, network_id=None): - ''' + """ + return self._api.get(f'exclusions/{scrub(exclusion_id)}').json() + + def edit( + self, + exclusion_id, + name=None, + members=None, + start_time=None, + end_time=None, + timezone=None, + description=None, + frequency=None, + interval=None, + weekdays=None, + day_of_month=None, + enabled=None, + network_id=None, + ): + """ Edit an existing scan target exclusion. :devportal:`exclusions: edit ` @@ -266,7 +312,7 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, Modifying the name of an exclusion: >>> exclusion = tio.exclusions.edit(1, name='New Name') - ''' + """ # Lets start constructing the payload to be sent to the API... payload = self.details(exclusion_id) @@ -284,17 +330,23 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, payload['schedule']['enabled'] = self._check('enabled', enabled, bool) if payload['schedule']['enabled']: - frequency = self._check('frequency', frequency, str, - choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], - default=payload['schedule']['rrules'].get('freq') - if payload['schedule']['rrules'] is not None else 'ONETIME', - case='upper') - - # interval needs to be handled in schedule enabled excusion + frequency = self._check( + 'frequency', + frequency, + str, + choices=['ONETIME', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], + default=payload['schedule']['rrules'].get('freq') + if payload['schedule']['rrules'] is not None + else 'ONETIME', + case='upper', + ) + + # interval needs to be handled in schedule enabled exclusion rrules = { 'freq': frequency, 'interval': payload['schedule']['rrules'].get('interval', None) or 1 - if payload['schedule']['rrules'] is not None else 1 + if payload['schedule']['rrules'] is not None + else 1, } # frequency default value is designed for weekly and monthly based on below conditions @@ -304,14 +356,21 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, # and byweekday/bymonthday key not already exist, assign default values # - if schedule rrules is not None and defined in edit params, assign new values if frequency == 'WEEKLY': - rrules['byweekday'] = ','.join(self._check( - 'weekdays', weekdays, list, - choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - default=payload['schedule']['rrules'].get('byweekday', '').split() - or ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] - if payload['schedule']['rrules'] is not None else - ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], - case='upper')) + rrules['byweekday'] = ','.join( + self._check( + 'weekdays', + weekdays, + list, + choices=['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + default=payload['schedule']['rrules'] + .get('byweekday', '') + .split() + or ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] + if payload['schedule']['rrules'] is not None + else ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], + case='upper', + ) + ) # In the same vein as the frequency check, we're accepting # case-insensitive input, comparing it to our known list of # acceptable responses, then joining them all together into a @@ -319,9 +378,16 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, if frequency == 'MONTHLY': rrules['bymonthday'] = self._check( - 'day_of_month', day_of_month, int, choices=list(range(1, 32)), - default=payload['schedule']['rrules'].get('bymonthday', datetime.today().day) - if payload['schedule']['rrules'] is not None else datetime.today().day) + 'day_of_month', + day_of_month, + int, + choices=list(range(1, 32)), + default=payload['schedule']['rrules'].get( + 'bymonthday', datetime.today().day + ) + if payload['schedule']['rrules'] is not None + else datetime.today().day, + ) # update new rrules in existing payload if payload['schedule']['rrules'] is not None: @@ -331,18 +397,22 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, if start_time: payload['schedule']['starttime'] = self._check( - 'start_time', start_time, datetime).strftime('%Y-%m-%d %H:%M:%S') + 'start_time', start_time, datetime + ).strftime('%Y-%m-%d %H:%M:%S') if end_time: payload['schedule']['endtime'] = self._check( - 'end_time', end_time, datetime).strftime('%Y-%m-%d %H:%M:%S') + 'end_time', end_time, datetime + ).strftime('%Y-%m-%d %H:%M:%S') if interval: payload['schedule']['rrules']['interval'] = self._check( - 'interval', interval, int) + 'interval', interval, int + ) payload['schedule']['timezone'] = self._check( - 'timezone', timezone, str, choices=self._api._tz, default='Etc/UTC') + 'timezone', timezone, str, choices=self._api._tz, default='Etc/UTC' + ) if network_id: payload['network_id'] = self._check('network_id', network_id, 'uuid') @@ -350,13 +420,10 @@ def edit(self, exclusion_id, name=None, members=None, start_time=None, # Lets check to make sure that the scanner_id and exclusion_id are # integers as the API documentation requests and if we don't raise an # error, then lets make the call. - return self._api.put( - 'exclusions/{}'.format( - self._check('exclusion_id', exclusion_id, int) - ), json=payload).json() + return self._api.put(f'exclusions/{scrub(exclusion_id)}', json=payload).json() def list(self): - ''' + """ List the currently configured scan target exclusions. :devportal:`exclusions: list ` @@ -368,11 +435,11 @@ def list(self): Examples: >>> for exclusion in tio.exclusions.list(): ... pprint(exclusion) - ''' + """ return self._api.get('exclusions').json()['exclusions'] def exclusions_import(self, fobj): - ''' + """ Import exclusions into Tenable Vulnerability Management. :devportal:`exclusions: import ` @@ -389,6 +456,6 @@ def exclusions_import(self, fobj): Examples: >>> with open('import_example.csv') as exclusion: ... tio.exclusions.exclusions_import(exclusion) - ''' + """ fid = self._api.files.upload(fobj) return self._api.post('exclusions/import', json={'file': fid}) diff --git a/tenable/io/exports/api.py b/tenable/io/exports/api.py index e2dec92b0..e9bdbc4b4 100644 --- a/tenable/io/exports/api.py +++ b/tenable/io/exports/api.py @@ -19,9 +19,9 @@ from typing import Any, Literal, Type from uuid import UUID -from restfly.errors import RequestConflictError - from tenable.base.endpoint import APIEndpoint +from tenable.errors import RequestConflictError +from tenable.utils import scrub from . import models from .iterator import ExportsIterator @@ -127,7 +127,7 @@ def cancel( 'CANCELLED' """ path = EXPORTS_MAP[export_type][version]['job_path'] - return self._api.post(f'{path}/{export_uuid}/cancel', box=True).status + return self._api.post(f'{path}/{scrub(export_uuid)}/cancel', box=True).status def download_chunk( self, @@ -172,7 +172,9 @@ def download_chunk( path = EXPORTS_MAP[export_type][version]['job_path'] while not downloaded and counter <= retries: try: - resp = self._api.get(f'{path}/{export_uuid}/chunks/{chunk_id}').json() + resp = self._api.get( + f'{path}/{scrub(export_uuid)}/chunks/{scrub(chunk_id)}' + ).json() downloaded = True except JSONDecodeError: self._log.warning( @@ -219,7 +221,7 @@ def status( """ path = EXPORTS_MAP[export_type][version]['job_path'] return self._api.get( - f'{path}/{export_uuid}/status', + f'{path}/{scrub(export_uuid)}/status', box=True, ) diff --git a/tenable/io/exports/iterator.py b/tenable/io/exports/iterator.py index 90d2010cf..2534676c3 100644 --- a/tenable/io/exports/iterator.py +++ b/tenable/io/exports/iterator.py @@ -15,7 +15,7 @@ from typing import Any, Callable from box import Box -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.errors import TioExportsError, TioExportsTimeout diff --git a/tenable/io/files.py b/tenable/io/files.py index 71a8da216..a5507ea93 100644 --- a/tenable/io/files.py +++ b/tenable/io/files.py @@ -13,7 +13,6 @@ ''' from typing import BinaryIO from .base import TIOEndpoint -import uuid class FileAPI(TIOEndpoint): diff --git a/tenable/io/folders.py b/tenable/io/folders.py index ff1150550..0d249bc9b 100644 --- a/tenable/io/folders.py +++ b/tenable/io/folders.py @@ -1,4 +1,4 @@ -''' +""" Folders ======= @@ -10,12 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: FoldersAPI :members: -''' +""" + +from tenable.utils import scrub + from .base import TIOEndpoint + class FoldersAPI(TIOEndpoint): def create(self, name): - ''' + """ Create a folder. :devportal:`folders: create ` @@ -30,13 +34,11 @@ def create(self, name): Examples: >>> folder = tio.folders.create('New Folder Name') - ''' - return self._api.post('folders', json={ - 'name': self._check('name', name, str) - }).json()['id'] + """ + return self._api.post('folders', json={'name': str(name)}).json()['id'] def delete(self, id): - ''' + """ Delete a folder. :devportal:`folders: delete ` @@ -49,11 +51,11 @@ def delete(self, id): Examples: >>> tio.folders.delete(1) - ''' - self._api.delete('folders/{}'.format(self._check('id', id, int))) + """ + self._api.delete(f'folders/{scrub(id)}') def edit(self, id, name): - ''' + """ Edit a folder. :devportal:`folders: edit ` @@ -68,13 +70,11 @@ def edit(self, id, name): Examples: >>> tio.folders.edit(1, 'Updated Folder Name') - ''' - self._api.put('folders/{}'.format(self._check('id', id, int)), json={ - 'name': self._check('name', name, str) - }) + """ + self._api.put(f'folders/{scrub(id)}', json={'name': str(name)}) def list(self): - ''' + """ Lists the available folders. :devportal:`folders: list ` @@ -86,5 +86,5 @@ def list(self): Examples: >>> for folder in tio.folders.list(): ... pprint(folder) - ''' + """ return self._api.get('folders').json()['folders'] diff --git a/tenable/io/groups.py b/tenable/io/groups.py index f88171ed8..88b9dd0e8 100644 --- a/tenable/io/groups.py +++ b/tenable/io/groups.py @@ -1,4 +1,4 @@ -''' +""" Groups ====== @@ -10,12 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: GroupsAPI :members: -''' +""" + +from tenable.utils import scrub + from .base import TIOEndpoint + class GroupsAPI(TIOEndpoint): def add_user(self, group_id, user_id): - ''' + """ Add a user to a user group. :devportal:`groups: add-user ` @@ -32,14 +36,11 @@ def add_user(self, group_id, user_id): Examples: >>> tio.groups.add_user(1, 1) - ''' - self._api.post('groups/{}/users/{}'.format( - self._check('group_id', group_id, int), - self._check('user_id', user_id, int), json={} - )) + """ + self._api.post(f'groups/{scrub(group_id)}/users/{scrub(user_id)}') def create(self, name): - ''' + """ Create a new user group. :devportal:`groups: create ` @@ -54,13 +55,13 @@ def create(self, name): Examples: >>> group = tio.groups.create('Group Name') - ''' - return self._api.post('groups', json={ - 'name': self._check('name', name, str) - }).json() + """ + return self._api.post( + 'groups', json={'name': self._check('name', name, str)} + ).json() def delete(self, id): - ''' + """ Delete a user group. :devportal:`groups: delete ` @@ -74,11 +75,11 @@ def delete(self, id): Examples: >>> tio.groups.delete(1) - ''' - self._api.delete('groups/{}'.format(self._check('id', id, int))) + """ + self._api.delete(f'groups/{scrub(id)}') def delete_user(self, group_id, user_id): - ''' + """ Delete a user from a user group. :devportal:`groups: delete-user ` @@ -95,14 +96,11 @@ def delete_user(self, group_id, user_id): Examples: >>> tio.groups.delete_user(1, 1) - ''' - self._api.delete('groups/{}/users/{}'.format( - self._check('group_id', group_id, int), - self._check('user_id', user_id, int) - )) + """ + self._api.delete(f'groups/{scrub(group_id)}/users/{scrub(user_id)}') def edit(self, id, name): - ''' + """ Edit a user group. :devportal:`groups: edit ` @@ -119,12 +117,13 @@ def edit(self, id, name): Examples: >>> tio.groups.edit(1, 'Updated name') - ''' - return self._api.put('groups/{}'.format(self._check('id', id, int)), - json={'name': self._check('name', name, str)}).json() + """ + return self._api.put( + f'groups/{scrub(id)}', json={'name': self._check('name', name, str)} + ).json() def list(self): - ''' + """ Lists all of the available user groups. :devportal:`groups: list ` @@ -136,11 +135,11 @@ def list(self): Examples: >>> for group in tio.groups.list(): ... pprint(group) - ''' + """ return self._api.get('groups').json()['groups'] def list_users(self, id): - ''' + """ List the user memberships within a specific user group. :devportal:`groups: list-users ` @@ -156,7 +155,5 @@ def list_users(self, id): Example: >>> for user in tio.groups.list_users(1): ... pprint(user) - ''' - return self._api.get('groups/{}/users'.format( - self._check('id', id, int))).json()['users'] - + """ + return self._api.get(f'groups/{scrub(id)}/users').json()['users'] diff --git a/tenable/io/networks.py b/tenable/io/networks.py index c4faa4064..6c4a03205 100644 --- a/tenable/io/networks.py +++ b/tenable/io/networks.py @@ -1,4 +1,4 @@ -''' +""" Networks ======== @@ -10,13 +10,15 @@ .. rst-class:: hide-signature .. autoclass:: NetworksAPI :members: -''' -from tenable.io.base import TIOEndpoint, TIOIterator +""" + from tenable.errors import UnexpectedValueError +from tenable.io.base import TIOEndpoint, TIOIterator +from tenable.utils import scrub class NetworksIterator(TIOIterator): - ''' + """ The networks iterator provides a scalable way to work through networks result sets of any size. The iterator will walk through each page of data, returning one record at a time. If it reaches the end of a page of records, @@ -33,15 +35,18 @@ class NetworksIterator(TIOIterator): page_count (int): The number of record returned from the current page. total (int): The total number of records that exist for the current request. - ''' + """ + pass + class NetworksAPI(TIOEndpoint): - ''' + """ This will contain all methods related to networks - ''' + """ + def create(self, name, description=None, assets_ttl_days=None): - ''' + """ Creates a new network within Tenable Vulnerability Management :devportal:`networks: create ` @@ -60,18 +65,18 @@ def create(self, name, description=None, assets_ttl_days=None): Examples: >>> nw = tio.networks.create('Example') - ''' - if not description: - description = '' - - return self._api.post('networks', json={ - 'name': self._check('name', name, str), - 'description': self._check('description', description, str), - 'assets_ttl_days': self._check('assets_ttl_days', assets_ttl_days, int) - }).json() + """ + return self._api.post( + 'networks', + json={ + 'name': str(name), + 'description': str(description) if description is not None else '', + 'assets_ttl_days': self._check('assets_ttl_days', assets_ttl_days, int), + }, + ).json() def delete(self, network_id): - ''' + """ Deletes the specified network. :devportal:`networks: delete ` @@ -81,11 +86,11 @@ def delete(self, network_id): Examples: >>> tio.networks.delete('00000000-0000-0000-0000-000000000000') - ''' - self._api.delete('networks/{}'.format(self._check('network_id', network_id, 'uuid'))) + """ + self._api.delete(f'networks/{scrub(network_id)}') def details(self, network_id): - ''' + """ Retrieves the details of the specified network. :devportal:`networks: details ` @@ -95,12 +100,11 @@ def details(self, network_id): Examples: >>> nw = tio.networks.details('00000000-0000-0000-0000-000000000000') - ''' - return self._api.get('networks/{}'.format( - self._check('network_id', network_id, 'uuid'))).json() + """ + return self._api.get(f'networks/{scrub(network_id)}').json() def edit(self, network_id, name, description=None, assets_ttl_days=None): - ''' + """ Updates the specified network resource. :devportal:`networks: update ` @@ -122,19 +126,21 @@ def edit(self, network_id, name, description=None, assets_ttl_days=None): Examples: >>> nw = tio.networks.edit('00000000-0000-0000-0000-000000000000', ... 'Updated Network', 'Updated Description', 180) - ''' + """ if not description: description = '' - return self._api.put('networks/{}'.format(self._check('network_id', network_id, 'uuid')), + return self._api.put( + f'networks/{scrub(network_id)}', json={ 'name': self._check('name', name, str), 'description': self._check('description', description, str), - 'assets_ttl_days': self._check('assets_ttl_days', assets_ttl_days, int) - }).json() + 'assets_ttl_days': self._check('assets_ttl_days', assets_ttl_days, int), + }, + ).json() def assign_scanners(self, network_id, *scanner_uuids): - ''' + """ Assigns one or many scanners to a network. :devportal:`networks: assign-scanner ` @@ -157,22 +163,26 @@ def assign_scanners(self, network_id, *scanner_uuids): ... '00000000-0000-0000-0000-000000000000', # Network UUID ... '00000000-0000-0000-0000-000000000000', # Scanner1 UUID ... '00000000-0000-0000-0000-000000000000') # Scanner2 UUID - ''' + """ if len(scanner_uuids) == 1: - self._api.post('networks/{}/scanners/{}'.format( - self._check('network_id', network_id, 'uuid'), - self._check('scanner_uuid', scanner_uuids[0], 'scanner-uuid') - )) + self._api.post( + f'networks/{scrub(network_id)}/scanners/{scrub(scanner_uuids[0])}' + ) elif len(scanner_uuids) > 1: - self._api.post('networks/{}/scanners'.format( - self._check('network_id', network_id, 'uuid')), - json={'scanner_uuids': [self._check('scanner_uuid', i, 'scanner-uuid') - for i in scanner_uuids]}) + self._api.post( + f'networks/{scrub(network_id)}/scanners', + json={ + 'scanner_uuids': [ + self._check('scanner_uuid', i, 'scanner-uuid') + for i in scanner_uuids + ] + }, + ) else: raise UnexpectedValueError('No scanner_uuids were supplied.') def list_scanners(self, network_id): - ''' + """ Retrieves the list of scanners associated to a given network. :devportal:`networks: list-scanners ` @@ -188,12 +198,13 @@ def list_scanners(self, network_id): >>> network = '00000000-0000-0000-0000-000000000000' >>> for scanner in tio.networks.list_scanners(network): ... pprint(scanner) - ''' - return self._api.get('networks/{}/scanners'.format( - self._check('network_id', network_id, 'uuid'))).json()['scanners'] + """ + return self._api.get(f'networks/{scrub(network_id)}/scanners').json()[ + 'scanners' + ] def unassigned_scanners(self, network_id): - ''' + """ Retrieves the list of scanners that are currently unassigned to the given network. This will include scanners and scanner groups that are currently assigned to the default network. @@ -211,12 +222,13 @@ def unassigned_scanners(self, network_id): >>> network = '00000000-0000-0000-0000-000000000000' >>> for scanner in tio.networks.unassigned_scanners(network): ... pprint(scanner) - ''' - return self._api.get('networks/{}/assignable-scanners'.format( - self._check('network_id', network_id, 'uuid'))).json()['scanners'] + """ + return self._api.get( + f'networks/{scrub(network_id)}/assignable-scanners' + ).json()['scanners'] def list(self, *filters, **kw): - ''' + """ Get the listing of configured networks from Tenable Vulnerability Management. :devportal:`networks: list ` @@ -271,12 +283,13 @@ def list(self, *filters, **kw): >>> for nw in tio.access_groups.list(('name', 'match', 'win')): ... pprint(nw) - ''' + """ limit = 50 offset = 0 pages = None - query = self._parse_filters(filters, - self._api.filters.networks_filters(), rtype='colon') + query = self._parse_filters( + filters, self._api.filters.networks_filters(), rtype='colon' + ) # If the offset was set to something other than the default starting # point of 0, then we will update offset to reflect that. @@ -301,16 +314,24 @@ def list(self, *filters, **kw): # sort=field1:asc,field2:desc # if 'sort' in kw and self._check('sort', kw['sort'], tuple): - query['sort'] = ','.join(['{}:{}'.format( - self._check('sort_field', i[0], str), - self._check('sort_direction', i[1], str, choices=['asc', 'desc']) - ) for i in kw['sort']]) + query['sort'] = ','.join( + [ + '{}:{}'.format( + self._check('sort_field', i[0], str), + self._check( + 'sort_direction', i[1], str, choices=['asc', 'desc'] + ), + ) + for i in kw['sort'] + ] + ) # The filter_type determines how the filters are combined together. # The default is 'and', however you can always explicitly define 'and' # or 'or'. if 'filter_type' in kw and self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']): + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ): query['ft'] = kw['filter_type'] # The wild-card filter text refers to how the API will pattern match @@ -321,25 +342,28 @@ def list(self, *filters, **kw): # The wildcard_fields parameter allows the user to restrict the fields # that the wild-card pattern match pertains to. if 'wildcard_fields' in kw and self._check( - 'wildcard_fields', kw['wildcard_fields'], list): + 'wildcard_fields', kw['wildcard_fields'], list + ): query['wf'] = ','.join(kw['wildcard_fields']) if 'include_deleted' in kw and self._check( - 'include_deleted', kw['include_deleted'], bool): + 'include_deleted', kw['include_deleted'], bool + ): query['includeDeleted'] = kw['include_deleted'] # Return the Iterator. - return NetworksIterator(self._api, + return NetworksIterator( + self._api, _limit=limit, _offset=offset, _pages_total=pages, _query=query, _path='networks', - _resource='networks' + _resource='networks', ) - def network_asset_count(self, network_id, num_days): - ''' + def network_asset_count(self, network_id: str, num_days: int): + """ get the total number of assets in the network along with the number of assets that have not been seen for the specified number of days. @@ -357,7 +381,7 @@ def network_asset_count(self, network_id, num_days): Examples: >>> network = '00000000-0000-0000-0000-000000000000' >>> count = tio.networks.network_asset_count(network, 180) - ''' - return self._api.get('networks/{}/counts/assets-not-seen-in/{}'.format( - self._check('network_id', network_id, 'uuid'), - self._check('num_days', num_days, int))).json() + """ + return self._api.get( + f'networks/{scrub(network_id)}/counts/assets-not-seen-in/{int(num_days)}' + ).json() diff --git a/tenable/io/pci/attestations.py b/tenable/io/pci/attestations.py index 4e61be55f..27c43c81f 100644 --- a/tenable/io/pci/attestations.py +++ b/tenable/io/pci/attestations.py @@ -16,6 +16,8 @@ from typing import Any, List, Literal, Type from uuid import UUID +from tenable.utils import scrub + from ..base import TIOEndpoint from .iterators import ( PCIAttestationAssetsIterator, @@ -123,7 +125,7 @@ def disputes( Should an iterator be returned or a page of data? If set to `None`, the page will be returned instead of the iterable. """ - path = f'details/{str(id)}/disputes' + path = f'details/{scrub(id)}/disputes' params = {'sort': self._format_sorts(sort), 'limit': limit, 'offset': offset} if iterator: return iterator( @@ -160,7 +162,7 @@ def failures( Should an iterator be returned or a page of data? If set to `None`, the page will be returned instead of the iterable. """ - path = f'{str(id)}/failures/undisputed/list' + path = f'{scrub(id)}/failures/undisputed/list' params = {'sort': self._format_sorts(sort), 'limit': limit, 'offset': offset} if iterator: return iterator( @@ -197,7 +199,7 @@ def assets( Should an iterator be returned or a page of data? If set to `None`, the page will be returned instead of the iterable. """ - path = f'{str(id)}/assets/list' + path = f'{scrub(id)}/assets/list' params = {'sort': self._format_sorts(sort), 'limit': limit, 'offset': offset} if iterator: return iterator( diff --git a/tenable/io/pci/iterators.py b/tenable/io/pci/iterators.py index b3b3f5d2a..4855598ff 100644 --- a/tenable/io/pci/iterators.py +++ b/tenable/io/pci/iterators.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from uuid import UUID -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator if TYPE_CHECKING: from tenable.io import TenableIO diff --git a/tenable/io/permissions.py b/tenable/io/permissions.py index d9ac0934d..d6cd62089 100644 --- a/tenable/io/permissions.py +++ b/tenable/io/permissions.py @@ -1,4 +1,4 @@ -''' +""" Permissions =========== @@ -10,12 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: PermissionsAPI :members: -''' +""" + +from tenable.utils import scrub + from .base import TIOEndpoint + class PermissionsAPI(TIOEndpoint): def change(self, otype, id, *acls): - ''' + """ Modify the permission of a specific object. :devportal:`permissions: change ` @@ -36,19 +40,16 @@ def change(self, otype, id, *acls): .. _permissions documentation: https://developer.tenable.com/docs/permissions - ''' + """ # Check to make sure all of the ACLs are dictionaries. for item in acls: self._check('acl', item, dict) # Make the API call. - self._api.put('permissions/{}/{}'.format( - self._check('otype', otype, str), - self._check('id', id, int) - ), json={'acls': acls}) + self._api.put(f'permissions/{scrub(otype)}/{scrub(id)}', json={'acls': acls}) def list(self, otype, id): - ''' + """ List the permissions of a specific object. :devportal:`permissions: list ` @@ -62,9 +63,5 @@ def list(self, otype, id): Returns: :obj:`list`: The permission recourse record listings for the specified object. - ''' - return self._api.get( - 'permissions/{}/{}'.format( - self._check('otype', otype, str), - self._check('id', id, int) - )).json()['acls'] + """ + return self._api.get(f'permissions/{scrub(otype)}/{scrub(id)}').json()['acls'] diff --git a/tenable/io/plugins.py b/tenable/io/plugins.py index 1cb7b9f2e..23633b659 100644 --- a/tenable/io/plugins.py +++ b/tenable/io/plugins.py @@ -1,4 +1,4 @@ -''' +""" Plugins ======= @@ -10,13 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: PluginsAPI :members: -''' +""" + from datetime import date + from tenable.io.base import TIOEndpoint, TIOIterator +from tenable.utils import scrub class PluginIterator(TIOIterator): - ''' + """ The plugins iterator provides a scalable way to work through plugin result sets of any size. The iterator will walk through each page of data, returning one record at a time. If it reaches the end of a page of @@ -36,12 +39,13 @@ class PluginIterator(TIOIterator): populate_maptable (bool): Informs the iterator whether to construct the plugin to family maps for injecting the plugin family data into each item. - ''' + """ + _maptable = None populate_maptable = False def _populate_family_cache(self): - ''' + """ Generates the maptable to use to graft on the plugin family information to the plugins. Effectively what we doing is generating a dictionary of 2 subdictionaries. Each one of these is a simple hash table allowing @@ -54,11 +58,8 @@ def _populate_family_cache(self): is returned, as it seems to take this long to generate the data. We can focus on reducing this time later on with the introduction of multi-threaded iterators && async API calls. - ''' - self._maptable = { - 'plugins': dict(), - 'families': dict() - } + """ + self._maptable = {'plugins': dict(), 'families': dict()} for family in self._api.plugins.families(): self._maptable['families'][family['id']] = family['name'] @@ -73,7 +74,7 @@ def next(self): if not self._maptable and self.populate_maptable: self._populate_family_cache() - # If the maptable exists, then graft on the plugin family information + # If the map-table exists, then graft on the plugin family information # on to to the item. if self._maptable: try: @@ -81,20 +82,22 @@ def next(self): item['family_id'] = fid item['family_name'] = self._maptable['families'][fid] except KeyError: - self._log.warning("plugin id {} not found in plugin family".format(item['id'])) + self._log.warning( + 'plugin id {} not found in plugin family'.format(item['id']) + ) item['family_id'] = None item['family_name'] = None return item - class PluginsAPI(TIOEndpoint): - ''' + """ This will contain all methods related to plugins - ''' + """ + def families(self): - ''' + """ List the available plugin families. :devportal:`plugins: families ` @@ -106,11 +109,11 @@ def families(self): Examples: >>> for family in tio.plugins.families(): ... pprint(family) - ''' + """ return self._api.get('plugins/families').json()['families'] def family_details(self, family_id): - ''' + """ Retrieve the details for a specific plugin family. :devportal:`plugins: family-details ` @@ -125,13 +128,11 @@ def family_details(self, family_id): Examples: >>> family = tio.plugins.family_details(1) - ''' - return self._api.get('plugins/families/{}'.format( - self._check('family_id', family_id, int) - )).json() + """ + return self._api.get(f'plugins/families/{scrub(family_id)}').json() def plugin_details(self, plugin_id): - ''' + """ Retrieve the details for a specific plugin. :devportal:`plugins: plugin-details ` @@ -147,12 +148,11 @@ def plugin_details(self, plugin_id): Examples: >>> plugin = tio.plugins.plugin_details(19506) >>> pprint(plugin) - ''' - return self._api.get('plugins/plugin/{}'.format( - self._check('plugin_id', plugin_id, int))).json() + """ + return self._api.get(f'plugins/plugin/{scrub(plugin_id)}').json() def list(self, page=None, size=None, last_updated=None, num_pages=None): - ''' + """ Get the listing of plugin details from Tenable Vulnerability Management. :devportal:`plugins: list ` @@ -191,15 +191,18 @@ def list(self, page=None, size=None, last_updated=None, num_pages=None): >>> plugins.populate_maptable = True >>> for plugin in plugins: ... pprint(plugin) - ''' - return PluginIterator(self._api, + """ + return PluginIterator( + self._api, _api_version=2, _size=self._check('size', size, int, default=1000), _page_num=self._check('page', page, int, default=1), _query={ - 'last_updated': self._check('last_updated', last_updated, date, - default=date(1970, 1, 1)).strftime('%Y-%m-%d') + 'last_updated': self._check( + 'last_updated', last_updated, date, default=date(1970, 1, 1) + ).strftime('%Y-%m-%d') }, _pages_total=self._check('num_pages', num_pages, int), _path='plugins/plugin', - _resource='plugin_details') + _resource='plugin_details', + ) diff --git a/tenable/io/policies.py b/tenable/io/policies.py index 27e3e5a1e..33745ecd1 100644 --- a/tenable/io/policies.py +++ b/tenable/io/policies.py @@ -1,4 +1,4 @@ -''' +""" Policies ======== @@ -10,26 +10,30 @@ .. rst-class:: hide-signature .. autoclass:: PoliciesAPI :members: -''' -from .base import TIOEndpoint -from tenable.utils import policy_settings, dict_merge +""" + from io import BytesIO +from tenable.utils import dict_merge, policy_settings, scrub + +from .base import TIOEndpoint + + class PoliciesAPI(TIOEndpoint): def templates(self): - ''' + """ returns a dictionary of the scan policy templates using the format of dict['name'] = 'UUID'. This is useful for being able to define scan policy templates w/o having to remember the UUID for each individual one. - ''' + """ policies = dict() for item in self._api.editor.template_list('policy'): policies[item['name']] = item['uuid'] return policies def template_details(self, name): - ''' + """ Calls the editor API and parses the policy template config to return a document that closely matches what the API expects to be POSTed or PUTed via the policy create and configure methods. The compliance audits and @@ -49,7 +53,7 @@ def template_details(self, name): Please note that template_details is reverse-engineered from the responses from the editor API and isn't guaranteed to work. - ''' + """ # Get the policy template UUID tmpl = self.templates() @@ -59,10 +63,7 @@ def template_details(self, name): editor = self._api.editor.template_details('policy', tmpl_uuid) # define the initial skeleton of the scan object - scan = { - 'settings': policy_settings(editor['settings']), - 'uuid': editor['uuid'] - } + scan = {'settings': policy_settings(editor['settings']), 'uuid': editor['uuid']} # graft on the basic settings that aren't stored in any input sections. for item in editor['settings']['basic']['groups']: @@ -74,8 +75,7 @@ def template_details(self, name): # if the credentials sub-document exists, then lets walk down the # credentials dataset scan['credentials'] = { - 'current': self._api.editor.parse_creds( - editor['credentials']['data']) + 'current': self._api.editor.parse_creds(editor['credentials']['data']) } # We also need to gather the settings from the various credential @@ -84,15 +84,14 @@ def template_details(self, name): for citem in ctype['types']: if 'settings' in citem and citem['settings']: scan['settings'] = dict_merge( - scan['settings'], policy_settings( - citem['settings'])) + scan['settings'], policy_settings(citem['settings']) + ) if 'compliance' in editor: # if the audits sub-document exists, then lets walk down the # audits dataset. scan['compliance'] = { - 'current': self._api.editor.parse_audits( - editor['compliance']['data']) + 'current': self._api.editor.parse_audits(editor['compliance']['data']) } # We also need to add in the "compliance" settings into the scan @@ -100,20 +99,21 @@ def template_details(self, name): for item in editor['compliance']['data']: if 'settings' in item: scan['settings'] = dict_merge( - scan['settings'], policy_settings( - item['settings'])) + scan['settings'], policy_settings(item['settings']) + ) if 'plugins' in editor: # if the plugins sub-document exists, then lets walk down the # plugins dataset. scan['plugins'] = self._api.editor.parse_plugins( - 'policy', editor['plugins']['families'], tmpl_uuid) + 'policy', editor['plugins']['families'], tmpl_uuid + ) # return the scan document to the caller. return scan def configure(self, id, policy): - ''' + """ Configures an existing policy. :devportal:`policies: configure ` @@ -133,12 +133,11 @@ def configure(self, id, policy): >>> policy = tio.policies.details(1) >>> policy['settings']['name'] = 'Updated Policy Name' >>> tio.policies.configure(policy) - ''' - self._api.put('policies/{}'.format(self._check('id', id, int)), - json=self._check('policy', policy, dict)) + """ + self._api.put(f'policies/{scrub(id)}', json=self._check('policy', policy, dict)) def copy(self, id): - ''' + """ Duplicates a scan policy and returns the copy. :devportal:`policies: copy ` @@ -152,12 +151,11 @@ def copy(self, id): Example: >>> policy = tio.policies.copy(1) - ''' - return self._api.post('policies/{}/copy'.format( - self._check('id', id, int))).json() + """ + return self._api.post(f'policies/{scrub(id)}/copy').json() def create(self, policy): - ''' + """ Creates a new scan policy based on the policy dictionary passed. :devportal:`policies: configure ` @@ -176,12 +174,13 @@ def create(self, policy): >>> policy = tio.policies.template_details('basic') >>> policy['settings']['name'] = 'New Scan Policy' >>> info = tio.policies.create(policy) - ''' - return self._api.post('policies', - json=self._check('policy', policy, dict)).json() + """ + return self._api.post( + 'policies', json=self._check('policy', policy, dict) + ).json() def delete(self, id): - ''' + """ Delete a custom policy. :devportal:`policies: delete ` @@ -195,11 +194,11 @@ def delete(self, id): Examples: >>> tio.policies.delete(1) - ''' - self._api.delete('policies/{}'.format(self._check('id', id, int))) + """ + self._api.delete(f'policies/{scrub(id)}') def details(self, id): - ''' + """ Retrieve the details for a specific policy. :devportal:`policies: details ` @@ -213,12 +212,11 @@ def details(self, id): Examples: >>> policy = tio.policies.details(1) - ''' - return self._api.get('policies/{}'.format( - self._check('id', id, int))).json() + """ + return self._api.get(f'policies/{scrub(id)}').json() def policy_import(self, fobj): - ''' + """ Imports a policy into Tenable Vulnerability Management. :devportal:`policies: import ` @@ -234,12 +232,12 @@ def policy_import(self, fobj): Examples: >>> with open('example.nessus') as policy: ... tio.policies.policy_import(policy) - ''' + """ fid = self._api.files.upload(fobj) return self._api.post('policies/import', json={'file': fid}).json() def policy_export(self, id, fobj=None): - ''' + """ Exports a specified policy from Tenable Vulnerability Management. :devportal:`policies: export ` @@ -259,15 +257,14 @@ def policy_export(self, id, fobj=None): Examples: >>> with open('example.nessus', 'wb') as policy: ... tio.policies.policy_export(1, policy) - ''' + """ # If no file object was givent to us, then lets create a new BytesIO # object to dump the data into. if not fobj: fobj = BytesIO() # make the call to get the file. - resp = self._api.get('policies/{}/export'.format( - self._check('id', id, int)), stream=True) + resp = self._api.get(f'policies/{scrub(id)}/export', stream=True) # Stream the data into the file. for chunk in resp.iter_content(chunk_size=1024): @@ -280,7 +277,7 @@ def policy_export(self, id, fobj=None): return fobj def list(self): - ''' + """ List the available custom policies. :devportal:`policies: list ` @@ -292,5 +289,5 @@ def list(self): Examples: >>> for policy in tio.policies.list(): ... pprint(policy) - ''' + """ return self._api.get('policies').json()['policies'] diff --git a/tenable/io/remediation_scans.py b/tenable/io/remediation_scans.py index 55bcc60f4..1f956e66b 100644 --- a/tenable/io/remediation_scans.py +++ b/tenable/io/remediation_scans.py @@ -1,295 +1,305 @@ -''' -Remediation Scans -================= - -The following methods allow for interaction into the Tenable Vulnerability Management -:devportal:`Remediation scan create ` API endpoints. -:devportal:`Remediation scan list ` API endpoints. - -Methods available on ``tio.remediation_scans``: - -.. rst-class:: hide-signature -.. autoclass:: RemediationScansAPI - :members: -''' - - -from tenable.constants import IOConstants -from tenable.errors import UnexpectedValueError -from tenable.io.base import TIOEndpoint, TIOIterator -from tenable.utils import dict_merge - - -class RemediationScansIteratorV2(TIOIterator): - ''' - The Remediation scans iterator provides a scalable way to work through - scan history result sets of any size. The iterator will walk through - each page of data, returning one record at a time. If it reaches the - end of a page of records, then it will request the next page of - information and then continue to return records from the next page - (and the next, and the next) until the counter reaches the total number - of records that the API has reported. - - Attributes: - count (int): The current number of records that have been returned - page (list): - The current page of data being walked through. pages will be - cycled through as the iterator requests more information from the - API. - page_count (int): The number of record returned from the current page. - total (int): - The total number of records that exist for the current request. - ''' - pass - - -class RemediationScansAPI(TIOEndpoint): - def list_remediation_scan(self, - limit=50, - offset=0, - sortval='scan_creation_date:desc'): - ''' - Retrieve the list of Remediation scans. - - :devportal:`scans: list_remediation_scan ` - - Args: - limit (int, optional): This value needs to be between 0 and 200 - offset (int, optional): This value needs to be > 0 - sort (str, optional): - scan_creation_date:desc/scan_creation_date:asc - Returns the remediation scan list with the ascending - or descending order with offset and limit - - Returns: - :obj:`RemediationScansIteratorV2`: - An iterator that handles the page management of the requested - records. - - Examples: - - >>> for remediation_scan in tio.scans.list(): - ... pprint(remediation_scan) - - For further information on credentials, what settings to use, etc, - refer to the doc linked above on the developer portal. - - ''' - params = dict() - pages = None - if limit>0 and limit < 200: - params['limit'] = self._check('limit', limit, int) - if offset >= 0: - params['offset'] = self._check('offset', offset, int) - if 'scan_creation_date:asc' or 'scan_creation_date:desc' in sortval: - params['sort'] = self._check('sort', sortval, str) - - return RemediationScansIteratorV2(self._api, - _limit=limit, - _offset=offset, - _pages_total=pages, - _query=params, - _path='scans/remediation', - _resource='scans') - - def create_remediation_scan(self, **kwargs): - ''' - Create a new remediation scan. - - :devportal:`scans: create_remediation_scan ` # noqa: E501 - - Args: - uuid (str, optional): UUID of Remediation scan template - name (str): The name of the remediation scan to create. - description (str, optional): The name of the scan to create. - policy (int, optional): - The id or title of the scan policy to use (if not using one of - the pre-defined templates). Specifying a a policy id will - override the the template parameter. - scanner_id (str, optional): The unique id of the scanner to use. - Use the GET /scanners endpoint to find the scanner ID. - You can use the special value AUTO-ROUTED to assign scan - targets to scanner groups based on the groups' configured - scan routes. - target_network_uuid (str, optional): For remediation scans, enter - a valid target network UUID from a previous scan you wish to - remediate. - scan_time_window (int, optional): The time frame, in minutes, - during which agents must transmit scan results to Tenable Vulnerability Management - in order to be included in dashboards and reports. If your - request omits this parameter, the default value is 180 minutes. - For non-agent scans, this attribute is null. - text_targets (str, optional): The List of targets to scan - targets (list, optional): - If defined, then a list of targets can be specified and will - be formatted to an appropriate text_target attribute. - A list of targets to scan - target_groups (list[int]): - For remediation scans, enter a valid target group ID from a - previous scan you wish to remediate. - file_targets (string, optional): - The name of a file containing the list of targets to scan. - tag_targets (list[str], optional): - The list of asset tag identifiers that the scan uses to - determine which assets it evaluates - agent_group_id (list[str], optional): - An array of agent group UUIDs to scan. - emails (list[str], optional): - A comma-separated list of accounts that receive the email - summary report. - acls (list[dict], optional): - A list of dictionaries containing permissions to apply to the - scan. - credentials (dict, optional): - A list of credentials to use. - enabled_plugins (list, optional): - A list of plugins IDs to add to a remediation scan. - **kw (dict, optional): - The various parameters that can be passed to the scan creation - API. Examples would be `name`, `email`, `scanner_id`, etc. - For more detailed information, please refer to the API - documentation linked above. Further, any keyword arguments - passed that are not explicitly documented will be automatically - appended to the settings document. There is no need to pass - settings directly. - - Returns: - :obj:`dict`: - The scan resource record of the newly created remediation scan. - - Examples: - Create remediation scan: - - - >>> scan = tio.remediationscans.create_remediation_scan( - ... uuid='76d67790-2969-411e-a9d0-667f05e8d49e', - ... name='Create Remediation Scan', - ... description='Remediation scan created', - ... scanner_id='10167769', - ... scan_time_window=10, - ... targets=['127.0.0.1:3000'], - ... template='advanced') - - For further information on credentials, what settings to use, etc, - refer to - `this doc `_ - on the developer portal. - - ''' - - if 'template' not in kwargs: - kwargs['template'] = 'advanced' - - scan = self._create_scan_document(kwargs) - - # Run the API call and return the result to the caller. - return self._api.post('scans/remediation', json=scan).json()['scan'] - - def _create_scan_document(self, kwargs): - ''' - Takes the key-worded arguments and will provide a scan settings - document based on the values inputted. - - Args: - kwargs (dict): The keyword dict passed from the user - Returns: - :obj:`dict`: - The resulting scan document based on the kwargs provided. - - ''' - scan = { - 'settings': dict(), - } - - # If a template is specified, then we will pull the listing of available - # templates and set the policy UUID to match the template name given. - if 'template' in kwargs: - - templates = self._api.policies.templates() - scan['uuid'] = templates[self._check( - 'template', kwargs['template'], str, - default='advanced', - choices=list(templates.keys()) - )] - del kwargs['template'] - - # If a policy UUID is sent, then we will set the scan template UUID to - # be the UUID that was specified. - - if 'policy' in kwargs: - policies = self._api.policies.list() - match = False - - # Here we are going to iterate over each policy in the list, looking - # to see if we see a match in either the name or the id. If we do - # find a match, then we will use the first one that matches, pull - # the editor config, and then use the policy id and scan policy - # template uuid. - for item in policies: - if kwargs['policy'] in [item['name'], item['id']] and not match: - policy_tmpl = self._api.editor.details('scan/policy', item['id']) - scan['uuid'] = policy_tmpl['uuid'] - scan['settings']['policy_id'] = item['id'] - match = True - - # if no match was discovered, then raise an invalid warning. - if not match: - raise UnexpectedValueError('policy setting is invalid.') - del kwargs['policy'] - - # if the scanner attribute was set, then we will attempt to figure out - # what scanner to use. - if 'scanner' in kwargs: - scanners = self._api.scanners.allowed_scanners() - - # We will want to attempt to enumerate the scanner list and if - # we see a name match, replace the scanner name with the UUID - # of the scanner instead. - for item in scanners: - if item['name'] == kwargs['scanner']: - kwargs['scanner'] = item['id'] - - # we will always want to attempt to use the UUID first as it's - # the cheapest check that we can run. - scan['settings']['scanner_id'] = self._check( - 'scanner', kwargs['scanner'], 'scanner-uuid', - choices=[s['id'] for s in scanners]) - del kwargs['scanner'] - - # If the targets parameter is specified, then we will need to convert - # the list of targets to a comma-delimited string and then set the - # text_targets parameter with the result. - if 'targets' in kwargs: - - scan['settings']['text_targets'] = ','.join(self._check( - 'targets', kwargs['targets'], list)) - del kwargs['targets'] - - # For credentials, we will simply push the dictionary as-is into the - # the credentials.add sub-document. - if 'credentials' in kwargs: - scan['credentials'] = {'add': dict()} - scan['credentials']['add'] = self._check( - 'credentials', kwargs['credentials'], dict) - del kwargs['credentials'] - - # Just like with credentials, we will push the dictionary as-is into the - # correct sub-document of the scan definition. - if 'compliance' in kwargs: - scan['audits'] = self._check('compliance', kwargs['compliance'], dict) - del kwargs['compliance'] - - # for enabled_plugins, we will push the list of plugin ids as-is into the - # correct sub-document of the scan definition. - if 'enabled_plugins' in kwargs: - scan['enabled_plugins'] = [ - self._check('plugin_id', plugin_id, int) - for plugin_id in self._check('enabled_plugins', kwargs['enabled_plugins'], list)] - del kwargs['enabled_plugins'] - - # any other remaining keyword arguments will be passed into the settings - # sub-document. The bulk of the data should go here... - - scan['settings'] = dict_merge(scan['settings'], kwargs) - - return scan +""" +Remediation Scans +================= + +The following methods allow for interaction into the Tenable Vulnerability Management +:devportal:`Remediation scan create ` API endpoints. +:devportal:`Remediation scan list ` API endpoints. + +Methods available on ``tio.remediation_scans``: + +.. rst-class:: hide-signature +.. autoclass:: RemediationScansAPI + :members: +""" + +from tenable.errors import UnexpectedValueError +from tenable.io.base import TIOEndpoint, TIOIterator +from tenable.utils import dict_merge + + +class RemediationScansIteratorV2(TIOIterator): + """ + The Remediation scans iterator provides a scalable way to work through + scan history result sets of any size. The iterator will walk through + each page of data, returning one record at a time. If it reaches the + end of a page of records, then it will request the next page of + information and then continue to return records from the next page + (and the next, and the next) until the counter reaches the total number + of records that the API has reported. + + Attributes: + count (int): The current number of records that have been returned + page (list): + The current page of data being walked through. pages will be + cycled through as the iterator requests more information from the + API. + page_count (int): The number of record returned from the current page. + total (int): + The total number of records that exist for the current request. + """ + + pass + + +class RemediationScansAPI(TIOEndpoint): + def list_remediation_scan( + self, limit=50, offset=0, sortval='scan_creation_date:desc' + ): + """ + Retrieve the list of Remediation scans. + + :devportal:`scans: list_remediation_scan ` + + Args: + limit (int, optional): This value needs to be between 0 and 200 + offset (int, optional): This value needs to be > 0 + sort (str, optional): + scan_creation_date:desc/scan_creation_date:asc + Returns the remediation scan list with the ascending + or descending order with offset and limit + + Returns: + :obj:`RemediationScansIteratorV2`: + An iterator that handles the page management of the requested + records. + + Examples: + + >>> for remediation_scan in tio.scans.list(): + ... pprint(remediation_scan) + + For further information on credentials, what settings to use, etc, + refer to the doc linked above on the developer portal. + + """ + params = dict() + pages = None + if limit > 0 and limit < 200: + params['limit'] = self._check('limit', limit, int) + if offset >= 0: + params['offset'] = self._check('offset', offset, int) + if 'scan_creation_date:asc' or 'scan_creation_date:desc' in sortval: + params['sort'] = self._check('sort', sortval, str) + + return RemediationScansIteratorV2( + self._api, + _limit=limit, + _offset=offset, + _pages_total=pages, + _query=params, + _path='scans/remediation', + _resource='scans', + ) + + def create_remediation_scan(self, **kwargs): + """ + Create a new remediation scan. + + :devportal:`scans: create_remediation_scan ` # noqa: E501 + + Args: + uuid (str, optional): UUID of Remediation scan template + name (str): The name of the remediation scan to create. + description (str, optional): The name of the scan to create. + policy (int, optional): + The id or title of the scan policy to use (if not using one of + the pre-defined templates). Specifying a a policy id will + override the the template parameter. + scanner_id (str, optional): The unique id of the scanner to use. + Use the GET /scanners endpoint to find the scanner ID. + You can use the special value AUTO-ROUTED to assign scan + targets to scanner groups based on the groups' configured + scan routes. + target_network_uuid (str, optional): For remediation scans, enter + a valid target network UUID from a previous scan you wish to + remediate. + scan_time_window (int, optional): The time frame, in minutes, + during which agents must transmit scan results to Tenable Vulnerability Management + in order to be included in dashboards and reports. If your + request omits this parameter, the default value is 180 minutes. + For non-agent scans, this attribute is null. + text_targets (str, optional): The List of targets to scan + targets (list, optional): + If defined, then a list of targets can be specified and will + be formatted to an appropriate text_target attribute. + A list of targets to scan + target_groups (list[int]): + For remediation scans, enter a valid target group ID from a + previous scan you wish to remediate. + file_targets (string, optional): + The name of a file containing the list of targets to scan. + tag_targets (list[str], optional): + The list of asset tag identifiers that the scan uses to + determine which assets it evaluates + agent_group_id (list[str], optional): + An array of agent group UUIDs to scan. + emails (list[str], optional): + A comma-separated list of accounts that receive the email + summary report. + acls (list[dict], optional): + A list of dictionaries containing permissions to apply to the + scan. + credentials (dict, optional): + A list of credentials to use. + enabled_plugins (list, optional): + A list of plugins IDs to add to a remediation scan. + **kw (dict, optional): + The various parameters that can be passed to the scan creation + API. Examples would be `name`, `email`, `scanner_id`, etc. + For more detailed information, please refer to the API + documentation linked above. Further, any keyword arguments + passed that are not explicitly documented will be automatically + appended to the settings document. There is no need to pass + settings directly. + + Returns: + :obj:`dict`: + The scan resource record of the newly created remediation scan. + + Examples: + Create remediation scan: + + + >>> scan = tio.remediationscans.create_remediation_scan( + ... uuid='76d67790-2969-411e-a9d0-667f05e8d49e', + ... name='Create Remediation Scan', + ... description='Remediation scan created', + ... scanner_id='10167769', + ... scan_time_window=10, + ... targets=['127.0.0.1:3000'], + ... template='advanced') + + For further information on credentials, what settings to use, etc, + refer to + `this doc `_ + on the developer portal. + + """ + + if 'template' not in kwargs: + kwargs['template'] = 'advanced' + + scan = self._create_scan_document(kwargs) + + # Run the API call and return the result to the caller. + return self._api.post('scans/remediation', json=scan).json()['scan'] + + def _create_scan_document(self, kwargs): + """ + Takes the key-worded arguments and will provide a scan settings + document based on the values inputted. + + Args: + kwargs (dict): The keyword dict passed from the user + Returns: + :obj:`dict`: + The resulting scan document based on the kwargs provided. + + """ + scan = { + 'settings': dict(), + } + + # If a template is specified, then we will pull the listing of available + # templates and set the policy UUID to match the template name given. + if 'template' in kwargs: + templates = self._api.policies.templates() + scan['uuid'] = templates[ + self._check( + 'template', + kwargs['template'], + str, + default='advanced', + choices=list(templates.keys()), + ) + ] + del kwargs['template'] + + # If a policy UUID is sent, then we will set the scan template UUID to + # be the UUID that was specified. + + if 'policy' in kwargs: + policies = self._api.policies.list() + match = False + + # Here we are going to iterate over each policy in the list, looking + # to see if we see a match in either the name or the id. If we do + # find a match, then we will use the first one that matches, pull + # the editor config, and then use the policy id and scan policy + # template uuid. + for item in policies: + if kwargs['policy'] in [item['name'], item['id']] and not match: + policy_tmpl = self._api.editor.details('scan/policy', item['id']) + scan['uuid'] = policy_tmpl['uuid'] + scan['settings']['policy_id'] = item['id'] + match = True + + # if no match was discovered, then raise an invalid warning. + if not match: + raise UnexpectedValueError('policy setting is invalid.') + del kwargs['policy'] + + # if the scanner attribute was set, then we will attempt to figure out + # what scanner to use. + if 'scanner' in kwargs: + scanners = self._api.scanners.allowed_scanners() + + # We will want to attempt to enumerate the scanner list and if + # we see a name match, replace the scanner name with the UUID + # of the scanner instead. + for item in scanners: + if item['name'] == kwargs['scanner']: + kwargs['scanner'] = item['id'] + + # we will always want to attempt to use the UUID first as it's + # the cheapest check that we can run. + scan['settings']['scanner_id'] = self._check( + 'scanner', + kwargs['scanner'], + 'scanner-uuid', + choices=[s['id'] for s in scanners], + ) + del kwargs['scanner'] + + # If the targets parameter is specified, then we will need to convert + # the list of targets to a comma-delimited string and then set the + # text_targets parameter with the result. + if 'targets' in kwargs: + scan['settings']['text_targets'] = ','.join( + self._check('targets', kwargs['targets'], list) + ) + del kwargs['targets'] + + # For credentials, we will simply push the dictionary as-is into the + # the credentials.add sub-document. + if 'credentials' in kwargs: + scan['credentials'] = {'add': dict()} + scan['credentials']['add'] = self._check( + 'credentials', kwargs['credentials'], dict + ) + del kwargs['credentials'] + + # Just like with credentials, we will push the dictionary as-is into the + # correct sub-document of the scan definition. + if 'compliance' in kwargs: + scan['audits'] = self._check('compliance', kwargs['compliance'], dict) + del kwargs['compliance'] + + # for enabled_plugins, we will push the list of plugin ids as-is into the + # correct sub-document of the scan definition. + if 'enabled_plugins' in kwargs: + scan['enabled_plugins'] = [ + self._check('plugin_id', plugin_id, int) + for plugin_id in self._check( + 'enabled_plugins', kwargs['enabled_plugins'], list + ) + ] + del kwargs['enabled_plugins'] + + # any other remaining keyword arguments will be passed into the settings + # sub-document. The bulk of the data should go here... + + scan['settings'] = dict_merge(scan['settings'], kwargs) + + return scan diff --git a/tenable/io/scanner_groups.py b/tenable/io/scanner_groups.py index 325fab3cb..03c63ca32 100644 --- a/tenable/io/scanner_groups.py +++ b/tenable/io/scanner_groups.py @@ -1,4 +1,4 @@ -''' +""" Scanner Groups ============== @@ -10,15 +10,19 @@ .. rst-class:: hide-signature .. autoclass:: ScannerGroupsAPI :members: -''' +""" + from tenable.io.base import TIOEndpoint +from tenable.utils import scrub + class ScannerGroupsAPI(TIOEndpoint): - ''' + """ This will contain all methods related to scanner groups - ''' + """ + def add_scanner(self, group_id, scanner_id): - ''' + """ Add a scanner to a scanner group. :devportal:`scanner-groups: add-scanner ` @@ -35,14 +39,11 @@ def add_scanner(self, group_id, scanner_id): Examples: >>> tio.scanner_groups.add_scanner(1, 1) - ''' - self._api.post('scanner-groups/{}/scanners/{}'.format( - self._check('group_id', group_id, int), - self._check('scanner_id', scanner_id, int) - )) + """ + self._api.post(f'scanner-groups/{scrub(group_id)}/scanners/{scrub(scanner_id)}') def create(self, name, group_type=None): - ''' + """ Create a scanner group. :devportal:`scanner-groups: create ` @@ -59,15 +60,23 @@ def create(self, name, group_type=None): Example: >>> group = tio.scanner_groups.create('Scanner Group') - ''' - return self._api.post('scanner-groups', json={ - 'name': self._check('name', name, str), - 'type': self._check('group_type', group_type, str, - default='load_balancing', choices=['load_balancing']) - }).json() + """ + return self._api.post( + 'scanner-groups', + json={ + 'name': self._check('name', name, str), + 'type': self._check( + 'group_type', + group_type, + str, + default='load_balancing', + choices=['load_balancing'], + ), + }, + ).json() def delete(self, group_id): - ''' + """ Deletes a scanner group. :devportal:`scanner-groups: delete ` @@ -81,11 +90,11 @@ def delete(self, group_id): Examples: >>> tio.scanner_groups.delete(1) - ''' - self._api.delete('scanner-groups/{}'.format(self._check('group_id', group_id, int))) + """ + self._api.delete(f'scanner-groups/{scrub(group_id)}') def delete_scanner(self, group_id, scanner_id): - ''' + """ Removes a scanner from a scanner group. :devportal:`scanner-groups: delete-scanner ` @@ -103,14 +112,13 @@ def delete_scanner(self, group_id, scanner_id): Examples: >>> tio.scanner_groups.delete_scanner(1, 1) - ''' - self._api.delete('scanner-groups/{}/scanners/{}'.format( - self._check('group_id', group_id, int), - self._check('scanner_id', scanner_id, int) - )) + """ + self._api.delete( + f'scanner-groups/{scrub(group_id)}/scanners/{scrub(scanner_id)}' + ) def details(self, group_id): - ''' + """ Retrieves the details about a scanner group. :devportal:`scanner-groups: details ` @@ -125,12 +133,11 @@ def details(self, group_id): Examples: >>> group = tio.scanner_groups.details(1) >>> pprint(group) - ''' - return self._api.get('scanner-groups/{}'.format( - self._check('group_id', group_id, int))).json() + """ + return self._api.get(f'scanner-groups/{scrub(group_id)}').json() def edit(self, group_id, name): - ''' + """ Modifies a scanner group. :devportal:`scanner-groups: edit ` @@ -145,14 +152,14 @@ def edit(self, group_id, name): Examples: >>> tio.scanner_groups.edit(1, 'New Group Name') - ''' - self._api.put('scanner-groups/{}'.format( - self._check('group_id', group_id, int)), json={ - 'name': self._check('name', name, str) - }) + """ + self._api.put( + f'scanner-groups/{scrub(group_id)}', + json={'name': self._check('name', name, str)}, + ) def list(self): - ''' + """ Lists the configured scanner groups. :devportal:`scanner-groups: list ` @@ -164,11 +171,11 @@ def list(self): Examples: >>> for group in tio.scanner_groups.list(): ... pprint(group) - ''' + """ return self._api.get('scanner-groups').json()['scanner_pools'] def list_scanners(self, group_id): - ''' + """ List the scanners within a specific scanner group. :devportal:`scanner-groups: list-scanners ` @@ -183,14 +190,15 @@ def list_scanners(self, group_id): Examples: >>> for scanner in tio.scanner_groups.list_scanners(1): ... pprint(scanner) - ''' - return self._api.get('scanner-groups/{}/scanners'.format( - self._check('group_id', group_id, int))).json()['scanners'] + """ + return self._api.get(f'scanner-groups/{scrub(group_id)}/scanners').json()[ + 'scanners' + ] def list_routes(self, group_id): - ''' - List the hostnames, wildcards, IP addresses, and IP address ranges that Tenable Vulnerability Management - matches against targets in auto-routed scans + """ + List the host-names, wildcards, IP addresses, and IP address ranges that + Tenable Vulnerability Management matches against targets in auto-routed scans :devportal:`scanner-groups: list-routes ` @@ -204,13 +212,14 @@ def list_routes(self, group_id): Examples: >>> for scanner in tio.scanner_groups.list_routes(1): ... pprint(scanner) - ''' - return self._api.get('scanner-groups/{}/routes'.format( - self._check('group_id', group_id, int))).json() + """ + return self._api.get( + 'scanner-groups/{}/routes'.format(self._check('group_id', group_id, int)) + ).json() def edit_routes(self, group_id, routes): - ''' - Updates the hostnames, hostname wildcards, IP addresses, and IP address ranges + """ + Updates the host-names, hostname wildcards, IP addresses, and IP address ranges that Tenable Vulnerability Management matches against targets in auto-routed scans :devportal:`scanner-groups: edit-routes ` @@ -225,8 +234,10 @@ def edit_routes(self, group_id, routes): Examples: >>> tio.scanner_groups.edit_routes(1, ['127.0.0.1']) - ''' + """ payload = {'routes': self._check('routes', routes, list)} - self._api.put('scanner-groups/{}/routes'.format( - self._check('group_id', group_id, int)), json=payload) + self._api.put( + f'scanner-groups/{scrub(group_id)}/routes', + json=payload, + ) diff --git a/tenable/io/scanners.py b/tenable/io/scanners.py index 3f23253ea..c324760fb 100644 --- a/tenable/io/scanners.py +++ b/tenable/io/scanners.py @@ -1,4 +1,4 @@ -''' +""" Scanners ======== @@ -10,16 +10,16 @@ .. rst-class:: hide-signature .. autoclass:: ScannersAPI :members: -''' -from .base import TIOEndpoint +""" -SCANNERS_ = 'scanners/{}' +from tenable.utils import scrub +from .base import TIOEndpoint -class ScannersAPI(TIOEndpoint): +class ScannersAPI(TIOEndpoint): def linking_key(self): - ''' + """ The linking key for the Tenable Vulnerability Management instance. Returns: @@ -28,14 +28,17 @@ def linking_key(self): Examples: >>> print(tio.scanners.linking_key()) - ''' + """ scanners = self.list() for scanner in scanners: - if scanner['uuid'] == '00000000-0000-0000-0000-00000000000000000000000000001': + if ( + scanner['uuid'] + == '00000000-0000-0000-0000-00000000000000000000000000001' + ): return scanner['key'] def allowed_scanners(self): - ''' + """ A simple convenience function that returns the list of scanners that the current user is allowed to use. @@ -46,7 +49,8 @@ def allowed_scanners(self): Examples: >>> for scanner in tio.scanners.allowed_scanners(): ... pprint(scanner) - ''' + """ + # We want to get the scanners that are available for scanning. To do so, # we will want to pull the information from the scan template. This # isn't the prettiest way to handle this, however it will consistently @@ -61,11 +65,13 @@ def get_scanners(tmpl): was_tmpl = self._api.policies.templates().get('was_scan', None) scanners = get_scanners(self._api.editor.template_details('scan', vm_tmpl)) if was_tmpl is not None: - scanners.extend(get_scanners(self._api.editor.template_details('scan', was_tmpl))) + scanners.extend( + get_scanners(self._api.editor.template_details('scan', was_tmpl)) + ) return scanners def control_scan(self, scanner_id, scan_uuid, action): - ''' + """ Perform actions against scans on a given scanner. :devportal:`scanners: control-scans ` @@ -87,15 +93,18 @@ def control_scan(self, scanner_id, scan_uuid, action): Stop a scan running on the scanner: >>> tio.scanners.control_scan(1, '00000000-0000-0000-0000-000000000000', 'stop') - ''' - self._api.post('scanners/{}/scans/{}/control'.format( - self._check('scanner_id', scanner_id, int), - self._check('scan_uuid', scan_uuid, str), - ), json={'action': self._check('action', action, str, - choices=['stop', 'pause', 'resume'])}) + """ + self._api.post( + f'scanners/{scrub(scanner_id)}/scans/{scrub(scan_uuid)}/control', + json={ + 'action': self._check( + 'action', action, str, choices=['stop', 'pause', 'resume'] + ) + }, + ) def delete(self, id): - ''' + """ Delete a scanner from Tenable Vulnerability Management. :devportal:`scanners: delete ` @@ -110,11 +119,11 @@ def delete(self, id): Examples: >>> tio.scanners.delete(1) - ''' - self._api.delete(SCANNERS_.format(self._check('id', id, int))) + """ + self._api.delete(f'scanners/{scrub(id)}') def details(self, id): - ''' + """ Retrieve the details for a specified scanner. :devportal:`scanners: details ` @@ -130,12 +139,11 @@ def details(self, id): Examples: >>> scanner = tio.scanners.details(1) >>> pprint(scanner) - ''' - return self._api.get(SCANNERS_.format( - self._check('id', id, int))).json() + """ + return self._api.get(f'scanners/{scrub(id)}').json() def edit(self, id, **kwargs): - ''' + """ Modify the scanner. :devportal:`scanners: edit ` @@ -164,29 +172,33 @@ def edit(self, id, **kwargs): Force a plugin update on a scanner: >>> tio.scanners.edit(1, force_plugin_update=True) - ''' + """ payload = dict() - if ('force_plugin_update' in kwargs - and self._check('force_plugin_update', kwargs['force_plugin_update'], bool)): + if 'force_plugin_update' in kwargs and self._check( + 'force_plugin_update', kwargs['force_plugin_update'], bool + ): payload['force_plugin_update'] = 1 - if ('force_ui_update' in kwargs - and self._check('force_ui_update', kwargs['force_ui_update'], bool)): + if 'force_ui_update' in kwargs and self._check( + 'force_ui_update', kwargs['force_ui_update'], bool + ): payload['force_ui_update'] = 1 - if ('finish_update' in kwargs - and self._check('finish_update', kwargs['finish_update'], bool)): + if 'finish_update' in kwargs and self._check( + 'finish_update', kwargs['finish_update'], bool + ): payload['finish_update'] = 1 - if ('registration_code' in kwargs - and self._check('registration_code', kwargs['registration_code'], str)): + if 'registration_code' in kwargs and self._check( + 'registration_code', kwargs['registration_code'], str + ): payload['registration_code'] = kwargs['registration_code'] - if ('aws_update_interval' in kwargs - and self._check('aws_update_interval', kwargs['aws_update_interval'], int)): + if 'aws_update_interval' in kwargs and self._check( + 'aws_update_interval', kwargs['aws_update_interval'], int + ): payload['aws_update_interval'] = kwargs['aws_update_interval'] - self._api.put(SCANNERS_.format(self._check('id', id, int)), - json=payload) + self._api.put(f'scanners/{scrub(id)}', json=payload) def get_aws_targets(self, id): - ''' + """ Returns the list of AWS targets the scanner can reach. :devportal:`scanners: get-aws-targets ` @@ -201,12 +213,11 @@ def get_aws_targets(self, id): Examples: >>> for target in tio.scanners.get_aws_targets(1): ... pprint(target) - ''' - return self._api.get('scanners/{}/aws-targets'.format( - self._check('id', id, int))).json()['targets'] + """ + return self._api.get(f'scanners/{scrub(id)}/aws-targets').json()['targets'] def get_scanner_key(self, id): - ''' + """ Return the key associated with the scanner. :devportal:`scanners: get-scanner-key ` @@ -220,12 +231,11 @@ def get_scanner_key(self, id): Examples: >>> print(tio.scanners.get_scanner_key(1)) - ''' - return str(self._api.get('scanners/{}/key'.format( - self._check('id', id, int))).json()['key']) + """ + return str(self._api.get(f'scanners/{scrub(id)}/key').json()['key']) def get_scans(self, id): - ''' + """ Retrieves the scans associated to the scanner. :devportal:`scanners: get-scans ` @@ -240,12 +250,11 @@ def get_scans(self, id): Examples: >>> for scan in tio.scanners.get_scans(1): ... pprint(scan) - ''' - return self._api.get('scanners/{}/scans'.format( - self._check('id', id, int))).json()['scans'] + """ + return self._api.get(f'scanners/{scrub(id)}/scans').json()['scans'] def list(self): - ''' + """ Retrieves the list of scanners. :devportal:`scanners: list ` @@ -257,11 +266,11 @@ def list(self): Examples: >>> for scanner in tio.scanners.list(): ... pprint(scanner) - ''' + """ return self._api.get('scanners').json()['scanners'] def toggle_link_state(self, id, linked): - ''' + """ Toggles the scanner's activated state. :devportal:`scanners: toggle-link-state ` @@ -280,12 +289,14 @@ def toggle_link_state(self, id, linked): to deactivate a linked scanner: >>> tio.scanners.toggle_link_state(1, False) - ''' - self._api.put('scanners/{}/link'.format(self._check('id', id, int)), - json={'link': int(self._check('linked', linked, bool))}) + """ + self._api.put( + f'scanners/{scrub(id)}/link', + json={'link': int(self._check('linked', linked, bool))}, + ) def get_permissions(self, id): - ''' + """ Returns the permission list for a given scanner. Args: @@ -297,11 +308,11 @@ def get_permissions(self, id): Examples: >>> tio.scanners.get_permissions(1) - ''' + """ return self._api.permissions.list('scanner', self._check('id', id, int)) def edit_permissions(self, id, *acls): - ''' + """ Modifies the permissions list for the given scanner. Args: @@ -316,5 +327,5 @@ def edit_permissions(self, id, *acls): >>> tio.scanners.edit_permissions(1, ... {'type': 'default, 'permissions': 16}, ... {'type': 'user', 'id': 2, 'permissions': 16}) - ''' + """ self._api.permissions.change('scanner', self._check('id', id, int), *acls) diff --git a/tenable/io/scans.py b/tenable/io/scans.py index 7cd16b369..f5a2dcf2a 100644 --- a/tenable/io/scans.py +++ b/tenable/io/scans.py @@ -14,17 +14,15 @@ import time import warnings -from datetime import datetime, timedelta +from datetime import datetime from io import BytesIO from typing import Callable, Dict, List, Optional, Tuple, Union from uuid import UUID -from restfly.utils import dict_clean - from tenable.constants import IOConstants from tenable.errors import UnexpectedValueError from tenable.io.base import TIOEndpoint, TIOIterator -from tenable.utils import dict_merge +from tenable.utils import dict_clean, dict_merge, scrub class ScanHistoryIterator(TIOIterator): @@ -614,7 +612,9 @@ def attachment( # Make the HTTP call and stream the data into the file object. resp = self._get( - f'{scan_id}/attachments/{attachment_id}', params={'key': key}, stream=True + f'{scrub(scan_id)}/attachments/{scrub(attachment_id)}', + params={'key': key}, + stream=True, ) for chunk in resp.iter_content(chunk_size=1024): if chunk: @@ -681,7 +681,7 @@ def configure(self, scan_id: Union[int, UUID], **kw) -> Dict: scan = dict_merge(current, updated) scan = self.upsert_aws_credentials(scan) # Performing the actual call to the API with the updated scan record. - return self._put(f'{str(scan_id)}', json=scan) + return self._put(f'{scrub(scan_id)}', json=scan) def upsert_aws_credentials(self, scan): """ @@ -758,7 +758,7 @@ def copy(self, scan_id, folder_id=None, name=None): payload['name'] = self._check('name', name, str) # make the call and return the resulting JSON document to the caller. - return self._api.post(f'scans/{str(scan_id)}/copy', json=payload).json() + return self._api.post(f'scans/{scrub(scan_id)}/copy', json=payload).json() def create(self, **kw): """ @@ -862,7 +862,7 @@ def delete(self, scan_id: Union[int, UUID]): Examples: >>> tio.scans.delete(1) """ - self._delete(f'{str(scan_id)}') + self._delete(f'{scrub(scan_id)}') def history( self, @@ -907,7 +907,7 @@ def history( _offset=offset if offset else 0, _pages_total=pages, _query=query, - _path=f'scans/{str(scan_id)}/history', + _path=f'scans/{scrub(scan_id)}/history', _resource='history', ) @@ -930,7 +930,7 @@ def delete_history(self, scan_id: Union[int, UUID], history_id: Union[int, UUID] Examples: >>> tio.scans.delete_history(1, 1) """ - self._delete(f'{str(scan_id)}/history/{str(history_id)}') + self._delete(f'{scrub(scan_id)}/history/{scrub(history_id)}') def details(self, scan_id: Union[int, UUID]) -> Dict: """ @@ -1010,10 +1010,11 @@ def results( warnings.warn( 'The history_uuid parameter is deprecated, use history_id instead', DeprecationWarning, + stacklevel=2, ) params['history_id'] = history_uuid - return self._api.get(f'scans/{str(scan_id)}', params=params).json() + return self._api.get(f'scans/{scrub(scan_id)}', params=params).json() def export( self, @@ -1167,14 +1168,14 @@ def f(response: requests.Response, # The first thing that we need to do is make the request and get the # File id for the job. fid = self._api.post( - f'scans/{str(scan_id)}/export', params=params, json=payload + f'scans/{scrub(scan_id)}/export', params=params, json=payload ).json()['file'] self._api._log.debug(f'Initiated scan export {str(fid)}') # Next we will wait for the status of the export request to become # ready. _ = self._wait_for_download( - f'scans/{str(scan_id)}/export/{str(fid)}/status', + f'scans/{scrub(scan_id)}/export/{scrub(fid)}/status', 'scans', scan_id, fid, @@ -1184,7 +1185,7 @@ def f(response: requests.Response, # Now that the status has reported back as "ready", we can actually # download the file. resp = self._api.get( - f'scans/{str(scan_id)}/export/{str(fid)}/download', + f'scans/{scrub(scan_id)}/export/{scrub(fid)}/download', params=dl_params, stream=True, ) @@ -1243,7 +1244,7 @@ def host_details( ) return self._api.get( - f'scans/{str(scan_id)}/hosts/{str(host_id)}', + f'scans/{scrub(scan_id)}/hosts/{scrub(host_id)}', params=params, ).json() @@ -1332,7 +1333,7 @@ def launch(self, scan_id: Union[int, UUID], targets: Optional[List[str]] = None) if targets: payload['alt_targets'] = targets - return self._post(f'{str(scan_id)}/launch', json=payload).scan_uuid + return self._post(f'{scrub(scan_id)}/launch', json=payload).scan_uuid def list( self, folder_id: Optional[int] = None, last_modified: Optional[datetime] = None @@ -1387,7 +1388,7 @@ def pause(self, scan_id: Union[int, UUID], block: bool = False): Examples: >>> tio.scans.pause(1) """ - self._post(f'{str(scan_id)}/pause', json={}) + self._post(f'{scrub(scan_id)}/pause', json={}) if block: self._block_while_running(scan_id) @@ -1427,7 +1428,7 @@ def plugin_output( params['history_uuid'] = history_uuid return self._get( - f'{str(scan_id)}/hosts/{str(host_id)}/plugins/{str(plugin_id)}', + f'{scrub(scan_id)}/hosts/{scrub(host_id)}/plugins/{scrub(plugin_id)}', params=params, ) @@ -1453,7 +1454,7 @@ def set_read_status(self, scan_id: Union[str, UUID], read_status: bool): >>> tio.scans.set_read_status(1, False) """ - self._put(f'{str(scan_id)}/status', json={'read': read_status}) + self._put(f'{scrub(scan_id)}/status', json={'read': read_status}) def resume(self, scan_id: Union[str, UUID]): """ @@ -1471,7 +1472,7 @@ def resume(self, scan_id: Union[str, UUID]): Examples: >>> tio.scans.resume(1) """ - self._post(f'{str(scan_id)}/resume') + self._post(f'{scrub(scan_id)}/resume') def schedule(self, scan_id: Union[str, UUID], enabled: bool) -> dict: """ @@ -1492,7 +1493,7 @@ def schedule(self, scan_id: Union[str, UUID], enabled: bool) -> dict: >>> tio.scans.schedule(1, True) """ - return self._put(f'{str(scan_id)}/schedule', json={'enabled': enabled}) + return self._put(f'{scrub(scan_id)}/schedule', json={'enabled': enabled}) def stop(self, scan_id: Union[str, UUID], block: bool = False): """ @@ -1518,7 +1519,7 @@ def stop(self, scan_id: Union[str, UUID], block: bool = False): >>> tio.scans.stop(1, True) """ - self._post(f'{str(scan_id)}/stop') + self._post(f'{scrub(scan_id)}/stop') if block: self._block_while_running(scan_id) @@ -1539,7 +1540,7 @@ def status(self, scan_id: Union[str, UUID]) -> str: >>> tio.scans.status(1) u'completed' """ - return self._get(f'{str(scan_id)}/latest-status').status + return self._get(f'{scrub(scan_id)}/latest-status').status def progress( self, @@ -1555,6 +1556,7 @@ def progress( Args: scan_id (int | UUID): The """ + scan_id: str = str(scan_id).strip('/.') params = {} if history_id: params['history_id'] = history_id @@ -1597,7 +1599,10 @@ def info(self, scan_id: Union[int, UUID], history_uuid: UUID) -> Dict: Examples: >>> info = tio.scans.info(1, 'BA0ED610-C27B-4096-A8F4-3189279AFFE7') """ - return self._api.get(f'scans/{str(scan_id)}/history/{str(history_uuid)}').json() + scan_id: str = str(scan_id).strip('/.') + return self._api.get( + f'scans/{scrub(scan_id)}/history/{scrub(history_uuid)}' + ).json() def check_auto_targets( self, diff --git a/tenable/io/session.py b/tenable/io/session.py index a5812efdb..1706cc085 100644 --- a/tenable/io/session.py +++ b/tenable/io/session.py @@ -1,4 +1,4 @@ -''' +""" Session ======= @@ -10,18 +10,19 @@ .. rst-class:: hide-signature .. autoclass:: SessionAPI :members: -''' +""" + from .base import TIOEndpoint class SessionAPI(TIOEndpoint): - ''' + """ Tenable Vulnerability Management session API is deprecated. it is recommended to use ``users`` endpoint instead - ''' + """ def edit(self, name, email): - ''' + """ Modify the currently logged-in user. :devportal:`session: edit ` @@ -36,14 +37,13 @@ def edit(self, name, email): Examples: >>> tio.session.edit('John Doe', 'joe@company.com') - ''' - return self._api.put('session', json={ - 'name': self._check('name', name, str), - 'email': self._check('email', email, str) - }).json() + """ + return self._api.put( + 'session', json={'name': str(name), 'email': str(email)} + ).json() def details(self): - ''' + """ Retrieve the current users resource record. :devportal:`session: get ` @@ -55,11 +55,11 @@ def details(self): Examples: >>> user = tio.session.details() >>> pprint(user) - ''' + """ return self._api.get('session').json() def change_password(self, old_password, new_password): - ''' + """ Change the password of the current user. :devportal:`session: password ` @@ -74,14 +74,17 @@ def change_password(self, old_password, new_password): Examples: >>> tio.session.change_password('old_pass', 'new_pass') - ''' - self._api.put('session/chpasswd', json={ - 'password': self._check('new_password', new_password, str), - 'current_password': self._check('old_password', old_password, str) - }) + """ + self._api.put( + 'session/chpasswd', + json={ + 'password': str(new_password), + 'current_password': str(old_password), + }, + ) def gen_api_keys(self): - ''' + """ Generate new API keys for the current user. :devportal:`session: keys ` @@ -92,11 +95,11 @@ def gen_api_keys(self): Examples: >>> keys = tio.session.gen_api_keys() - ''' + """ return self._api.put('session/keys').json() def two_factor(self, email, sms, phone=None): - ''' + """ Configure two-factor authorization. :devportal:`session: two-factor ` @@ -122,17 +125,17 @@ def two_factor(self, email, sms, phone=None): Configure SMS multi-factor auth: >>> tio.session.two_factor(False, True, '9998887766') - ''' + """ payload = { 'email_enabled': self._check('email', email, bool), - 'sms_enabled': self._check('sms', sms, bool) + 'sms_enabled': self._check('sms', sms, bool), } if phone: payload['sms_phone'] = self._check('phone', phone, str) self._api.put('session/two-factor', json=payload) def enable_two_factor(self, phone): - ''' + """ Initiate the phone-based two-factor authorization verification process. :devportal:`session: two-factor-enable ` @@ -146,13 +149,14 @@ def enable_two_factor(self, phone): Examples: >>> tio.session.enable_two_factor('9998887766') - ''' - self._api.post('session/two-factor/send-verification', json={ - 'sms_phone': self._check('phone', phone, str) - }) + """ + self._api.post( + 'session/two-factor/send-verification', + json={'sms_phone': self._check('phone', phone, str)}, + ) def verify_two_factor(self, code): - ''' + """ Send the verification code for two-factor authorization. :devportal:`session: verify-code ` @@ -166,13 +170,14 @@ def verify_two_factor(self, code): Examples: >>> tio.session.verify_two_factor('abc123') - ''' - self._api.post('session/two-factor/verify-code', json={ - 'verification_code': self._check('code', code, str) - }) + """ + self._api.post( + 'session/two-factor/verify-code', + json={'verification_code': self._check('code', code, str)}, + ) def restore(self): - ''' + """ Restore the session to the logged-in user. This will remove any user impersonation setting that have been set. @@ -184,7 +189,5 @@ def restore(self): Example: >>> tio.session.restore() - ''' - self._api._session.headers.update({ - 'X-Impersonate': None - }) + """ + self._api._session.headers.update({'X-Impersonate': None}) diff --git a/tenable/io/sync/iterator.py b/tenable/io/sync/iterator.py index ce48b7247..9795cb1f7 100644 --- a/tenable/io/sync/iterator.py +++ b/tenable/io/sync/iterator.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator -from .models.job import Job, LogLine +from .models.job import Job if TYPE_CHECKING: from tenable.io import TenableIO diff --git a/tenable/io/sync/job_manager.py b/tenable/io/sync/job_manager.py index 2360a99cc..02b8e3341 100644 --- a/tenable/io/sync/job_manager.py +++ b/tenable/io/sync/job_manager.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Literal from uuid import UUID -from restfly.errors import APIError, InvalidContentError +from tenable.errors import APIError, InvalidContentError from .models.cve_finding import CVEFinding from .models.device_asset import DeviceAsset diff --git a/tenable/io/sync/models/common.py b/tenable/io/sync/models/common.py index 18bc97aaf..c0e2f2a5a 100644 --- a/tenable/io/sync/models/common.py +++ b/tenable/io/sync/models/common.py @@ -15,7 +15,7 @@ from pydantic import ( BaseModel as PydanticBaseModel, ) -from restfly.utils import trunc +from tenable.utils import trunc from typing_extensions import Annotated if TYPE_CHECKING: diff --git a/tenable/io/sync/models/device_asset.py b/tenable/io/sync/models/device_asset.py index 5da6fd168..df131f3cb 100644 --- a/tenable/io/sync/models/device_asset.py +++ b/tenable/io/sync/models/device_asset.py @@ -4,13 +4,11 @@ from ipaddress import IPv4Address, IPv6Address from typing import Literal -from arrow import ArrowFactory from pydantic import ( AfterValidator, AwareDatetime, BeforeValidator, Field, - PlainSerializer, StringConstraints, ) from pydantic_extra_types.mac_address import MacAddress diff --git a/tenable/io/tags.py b/tenable/io/tags.py index e258ad91a..f77218a75 100644 --- a/tenable/io/tags.py +++ b/tenable/io/tags.py @@ -1,4 +1,4 @@ -''' +""" Tags ==== @@ -10,16 +10,17 @@ .. rst-class:: hide-signature .. autoclass:: TagsAPI :members: -''' +""" + import json import re -from tenable.utils import dict_merge from tenable.io.base import TIOEndpoint, TIOIterator +from tenable.utils import dict_merge, scrub class TagsIterator(TIOIterator): - ''' + """ The tags iterator provides a scalable way to work through tag list result sets of any size. The iterator will walk through each page of data, returning one record at a time. If it reaches the end of a page of @@ -36,114 +37,153 @@ class TagsIterator(TIOIterator): page_count (int): The number of record returned from the current page. total (int): The total number of records that exist for the current request. - ''' + """ + pass class TagsAPI(TIOEndpoint): - ''' + """ This will contain all methods related to tags - ''' + """ + _filterset_tags = { - 'value': { - 'operators': ['eq', 'match'], 'pattern': None, 'choices': None - }, + 'value': {'operators': ['eq', 'match'], 'pattern': None, 'choices': None}, 'category_name': { - 'operators': ['eq', 'match'], 'pattern': None, 'choices': None + 'operators': ['eq', 'match'], + 'pattern': None, + 'choices': None, }, 'category_uuid': { - 'operators': ['eq'], 'pattern': r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12,32}$', 'choices': None - }, - 'description': { - 'operators': ['eq', 'match'], 'pattern': None, 'choices': None + 'operators': ['eq'], + 'pattern': r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12,32}$', + 'choices': None, }, + 'description': {'operators': ['eq', 'match'], 'pattern': None, 'choices': None}, 'updated_at': { - 'operators': ['date-eq', 'date-gt', 'date-lt'], 'pattern': '\\d+', 'choices': None + 'operators': ['date-eq', 'date-gt', 'date-lt'], + 'pattern': '\\d+', + 'choices': None, }, - 'updated_by': { - 'operators': ['eq'], 'pattern': None - } # Add UUID regex here + 'updated_by': {'operators': ['eq'], 'pattern': None}, # Add UUID regex here } _filterset_categories = { - 'name': { - 'operators': ['eq', 'match'], 'pattern': None, 'choices': None - }, - 'description': { - 'operators': ['eq', 'match'], 'pattern': None, 'choices': None - }, + 'name': {'operators': ['eq', 'match'], 'pattern': None, 'choices': None}, + 'description': {'operators': ['eq', 'match'], 'pattern': None, 'choices': None}, 'created_at': { - 'operators': ['date-eq', 'date-gt', 'date-lt'], 'pattern': '\\d+', 'choices': None + 'operators': ['date-eq', 'date-gt', 'date-lt'], + 'pattern': '\\d+', + 'choices': None, }, 'updated_at': { - 'operators': ['date-eq', 'date-gt', 'date-lt'], 'pattern': '\\d+', 'choices': None + 'operators': ['date-eq', 'date-gt', 'date-lt'], + 'pattern': '\\d+', + 'choices': None, }, 'updated_by': { - 'operators': ['eq'], 'pattern': None, 'choices': None - } # Add UUID regex here + 'operators': ['eq'], + 'pattern': None, + 'choices': None, + }, # Add UUID regex here } def _permission_constructor(self, items): - ''' + """ Simple current_domain_permission tuple expander. Also supports validation of values - ''' + """ resp = list() for item in items: self._check('item', item, (tuple, dict)) if isinstance(item, tuple): if len(item) == 3: item = item + ([],) - resp.append({ - 'id': self._check('id', item[0], 'uuid'), - "name": self._check('name', item[1], str), - "type": self._check('type', item[2], str, - choices=['user', 'group'], case='upper'), - "permissions": [ - self._check('i', i, str, - choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'], case='upper') - for i in self._check('permissions', item[3], list)], - }) + resp.append( + { + 'id': self._check('id', item[0], 'uuid'), + 'name': self._check('name', item[1], str), + 'type': self._check( + 'type', + item[2], + str, + choices=['user', 'group'], + case='upper', + ), + 'permissions': [ + self._check( + 'i', + i, + str, + choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'], + case='upper', + ) + for i in self._check('permissions', item[3], list) + ], + } + ) else: data = dict() data['id'] = self._check('id', item['id'], 'uuid') data['name'] = self._check('name', item['name'], str) - data['type'] = self._check('type', item['type'], str, - choices=['user', 'group'], case='upper') + data['type'] = self._check( + 'type', item['type'], str, choices=['user', 'group'], case='upper' + ) data['permissions'] = [ - self._check('i', i, str, - choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'], case='upper') - for i in self._check('permissions', item['permissions'] - if 'permissions' in item else None, list, - default=list())] + self._check( + 'i', + i, + str, + choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'], + case='upper', + ) + for i in self._check( + 'permissions', + item['permissions'] if 'permissions' in item else None, + list, + default=list(), + ) + ] resp.append(data) return resp def _tag_value_constructor(self, filters, filterdefs, filter_type): - ''' + """ A simple constructor to handle constructing the filter parameters for the create and edit tag value. - ''' - filter_type = self._check('filter_type', filter_type, str, - choices=['and', 'or'], default='and', case='lower') + """ + filter_type = self._check( + 'filter_type', + filter_type, + str, + choices=['and', 'or'], + default='and', + case='lower', + ) # created default dictionary for payload filters key - payload_filters = dict({ - 'asset': dict({ - filter_type: list() - }) - }) + payload_filters = dict({'asset': dict({filter_type: list()})}) if len(filters) > 0: # run the filters through the filter parser and update payload_filters - parsed_filters = self._parse_filters(filters, filterdefs, rtype='assets')['asset'] + parsed_filters = self._parse_filters(filters, filterdefs, rtype='assets')[ + 'asset' + ] payload_filters['asset'][filter_type] = parsed_filters return payload_filters - def create(self, category, value, description=None, category_description=None, - filters=None, filter_type=None, all_users_permissions=None, - current_domain_permissions=None): - ''' + def create( + self, + category, + value, + description=None, + category_description=None, + filters=None, + filter_type=None, + all_users_permissions=None, + current_domain_permissions=None, + ): + """ Create a tag category/value pair :devportal:`tags: create-tag-value ` @@ -219,7 +259,7 @@ def create(self, category, value, description=None, category_description=None, Creating a new Tag Value within a Category by UUID: >>> tio.tags.create('00000000-0000-0000-0000-000000000000', 'Madison') - ''' + """ all_permissions = ['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'] payload = dict() @@ -228,7 +268,9 @@ def create(self, category, value, description=None, category_description=None, # parameter, if not (but is still a string), then we will pass into # category_name - uuid_pattern = re.compile(r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$') + uuid_pattern = re.compile( + r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$' + ) if uuid_pattern.search(category): payload['category_uuid'] = self._check('category', category, 'uuid') @@ -241,34 +283,45 @@ def create(self, category, value, description=None, category_description=None, payload['description'] = self._check('description', description, str) if category_description: payload['category_description'] = self._check( - 'category_description', category_description, str) + 'category_description', category_description, str + ) if not current_domain_permissions: current_domain_permissions = list() payload['access_control'] = { # setting default current_user_permissions to all 'current_user_permissions': all_permissions, - # check and assign all_users_permissions 'all_users_permissions': [ - self._check('i', i, str, choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS']) - for i in self._check('all_users_permissions', all_users_permissions, list, - default=list(), case='upper')], - + self._check( + 'i', i, str, choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'] + ) + for i in self._check( + 'all_users_permissions', + all_users_permissions, + list, + default=list(), + case='upper', + ) + ], # run the current_domain_permissions through the permission_constructor 'current_domain_permissions': self._permission_constructor( - self._check('current_domain_permissions', current_domain_permissions, list)), + self._check( + 'current_domain_permissions', current_domain_permissions, list + ) + ), } # if filters are defined, run the filters through the filter parser... if self._check('filters', filters, list): payload['filters'] = self._tag_value_constructor( - filters, self._api.filters.asset_tag_filters(), filter_type) + filters, self._api.filters.asset_tag_filters(), filter_type + ) return self._api.post('tags/values', json=payload).json() def create_category(self, name, description=None): - ''' + """ Creates a new category :devportal:`tags: create-category ` @@ -283,7 +336,7 @@ def create_category(self, name, description=None): Examples: >>> tio.tags.create_category('Location') - ''' + """ payload = dict() payload['name'] = self._check('name', name, str) if description: @@ -291,7 +344,7 @@ def create_category(self, name, description=None): return self._api.post('tags/categories', json=payload).json() def delete(self, *tag_value_uuids): - ''' + """ Deletes tag value(s). :devportal:`tag: delete tag value ` @@ -312,18 +365,26 @@ def delete(self, *tag_value_uuids): >>> tio.tags.delete('00000000-0000-0000-0000-000000000000', ... '10000000-0000-0000-0000-000000000001') - ''' + """ if len(tag_value_uuids) <= 1: - self._api.delete('tags/values/{}'.format( - self._check('tag_value_uuid', tag_value_uuids[0], 'uuid'))) + self._api.delete( + 'tags/values/{}'.format( + scrub(self._check('tag_value_uuid', tag_value_uuids[0], 'uuid')) + ) + ) else: - self._api.post('tags/values/delete-requests', - json={'values': [ - self._check('tag_value_uuid', i, 'uuid') for i in tag_value_uuids - ]}) + self._api.post( + 'tags/values/delete-requests', + json={ + 'values': [ + self._check('tag_value_uuid', i, 'uuid') + for i in tag_value_uuids + ] + }, + ) def delete_category(self, tag_category_uuid): - ''' + """ Deletes a tag category. :devportal:`tag: delete tag category ` @@ -337,12 +398,15 @@ def delete_category(self, tag_category_uuid): Examples: >>> tio.tags.delete('00000000-0000-0000-0000-000000000000') - ''' - self._api.delete('tags/categories/{}'.format( - self._check('tag_category_uuid', tag_category_uuid, 'uuid'))) + """ + self._api.delete( + 'tags/categories/{}'.format( + scrub(self._check('tag_category_uuid', tag_category_uuid, 'uuid')) + ) + ) def details(self, tag_value_uuid): - ''' + """ Retrieves the details for a specific tag category/value pair. :devportal:`tag: tag details ` @@ -357,12 +421,15 @@ def details(self, tag_value_uuid): Examples: >>> tio.tags.details('00000000-0000-0000-0000-000000000000') - ''' - return self._api.get('tags/values/{}'.format(self._check( - 'tag_value_uuid', tag_value_uuid, 'uuid'))).json() + """ + return self._api.get( + 'tags/values/{}'.format( + scrub(self._check('tag_value_uuid', tag_value_uuid, 'uuid')) + ) + ).json() def details_category(self, tag_category_uuid): - ''' + """ Retrieves the details for a specific tag category. :devportal:`tag: tag category details ` @@ -377,13 +444,24 @@ def details_category(self, tag_category_uuid): Examples: >>> tio.tags.details_category('00000000-0000-0000-0000-000000000000') - ''' - return self._api.get('tags/categories/{}'.format(self._check( - 'tag_category_uuid', tag_category_uuid, 'uuid'))).json() - - def edit(self, tag_value_uuid, value=None, description=None, filters=None, filter_type=None, - all_users_permissions=None, current_domain_permissions=None): - ''' + """ + return self._api.get( + 'tags/categories/{}'.format( + scrub(self._check('tag_category_uuid', tag_category_uuid, 'uuid')) + ) + ).json() + + def edit( + self, + tag_value_uuid, + value=None, + description=None, + filters=None, + filter_type=None, + all_users_permissions=None, + current_domain_permissions=None, + ): + """ Updates Tag category/value pair information. :devportal:`tag: edit tag value ` @@ -431,7 +509,7 @@ def edit(self, tag_value_uuid, value=None, description=None, filters=None, filte Examples: >>> tio.tags.edit('00000000-0000-0000-0000-000000000000', ... name='NewValueName') - ''' + """ payload = dict() payload['value'] = self._check('value', value, str) if description: @@ -451,17 +529,24 @@ def edit(self, tag_value_uuid, value=None, description=None, filters=None, filte # Set all users permission if all_users_permissions is not None: current_access_control['all_users_permissions'] = [ - self._check('i', i, str, choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS']) - for i in self._check('all_users_permissions', all_users_permissions, list, - case='upper')] + self._check( + 'i', i, str, choices=['ALL', 'CAN_EDIT', 'CAN_SET_PERMISSIONS'] + ) + for i in self._check( + 'all_users_permissions', all_users_permissions, list, case='upper' + ) + ] # run current_domain_permissions through permission parser if current_domain_permissions is not None: - current_access_control['current_domain_permissions'] = self._permission_constructor( - current_domain_permissions) + current_access_control['current_domain_permissions'] = ( + self._permission_constructor(current_domain_permissions) + ) # update payload access control with new values - payload['access_control'] = dict_merge(payload['access_control'], current_access_control) + payload['access_control'] = dict_merge( + payload['access_control'], current_access_control + ) # We need to pick current value of version if available or set default value to 0 # this value will be incremented when permissions are updated @@ -479,18 +564,23 @@ def edit(self, tag_value_uuid, value=None, description=None, filters=None, filte if filters is not None: self._check('filters', filters, list) payload['filters'] = self._tag_value_constructor( - filters, self._api.filters.asset_tag_filters(), filter_type) + filters, self._api.filters.asset_tag_filters(), filter_type + ) elif 'filters' in current and current['filters']: # current value in filters are in form of string. # we have to first convert it into dict() form before applying current['filters']['asset'] = json.loads(current['filters']['asset']) payload['filters'] = current['filters'] - return self._api.put('tags/values/{}'.format(self._check( - 'tag_value_uuid', tag_value_uuid, 'uuid')), json=payload).json() + return self._api.put( + 'tags/values/{}'.format( + scrub(self._check('tag_value_uuid', tag_value_uuid, 'uuid')), + json=payload, + ) + ).json() def edit_category(self, tag_category_uuid, name=None, description=None): - ''' + """ Updates Tag category information. :devportal:`tag: edit tag category ` @@ -510,32 +600,49 @@ def edit_category(self, tag_category_uuid, name=None, description=None): Examples: >>> tio.tags.edit_category('00000000-0000-0000-0000-000000000000', ... name='NewValueName') - ''' + """ payload = dict() payload['name'] = self._check('name', name, str) if description: payload['description'] = self._check('description', description, str) - return self._api.put('tags/categories/{}'.format(self._check( - 'tag_category_uuid', tag_category_uuid, 'uuid')), json=payload).json() + return self._api.put( + 'tags/categories/{}'.format( + scrub(self._check('tag_category_uuid', tag_category_uuid, 'uuid')), + json=payload, + ) + ).json() def _tag_list_constructor(self, filters, filterdefs, filter_type, sort): - ''' + """ A simple constructor to handle constructing the query parameters for the list and list_category methods. - ''' + """ query = self._parse_filters(filters, filterdefs, rtype='colon') if filter_type: - query['ft'] = self._check('filter_type', filter_type, str, - choices=['AND', 'OR'], case='upper') + query['ft'] = self._check( + 'filter_type', filter_type, str, choices=['AND', 'OR'], case='upper' + ) if sort and self._check('sort', sort, tuple): - query['sort'] = ','.join(['{}:{}'.format( - self._check('sort_field', i[0], str, choices=[k for k in filterdefs.keys()]), - self._check('sort_direction', i[1], str, choices=['asc', 'desc']) - ) for i in sort]) + query['sort'] = ','.join( + [ + '{}:{}'.format( + self._check( + 'sort_field', + i[0], + str, + choices=[k for k in filterdefs.keys()], + ), + self._check( + 'sort_direction', i[1], str, choices=['asc', 'desc'] + ), + ) + for i in sort + ] + ) return query def list(self, *filters, **kw): - ''' + """ Retrieves a list of tag category/value pairs based off of the filters defined within the query. @@ -577,21 +684,25 @@ def list(self, *filters, **kw): >>> for tag in tio.tags.list(('category_name', 'eq', 'Location')): ... pprint(tag) - ''' - query = self._tag_list_constructor(filters, self._filterset_tags, - kw['filter_type'] if 'filter_type' in kw else None, - kw['sort'] if 'sort' in kw else None) - return TagsIterator(self._api, - _limit=self._check('limit', kw.get('limit', 1000), int), - _offset=self._check('offset', kw.get('offset', 0), int), - _pages_total=self._check('pages', kw.get('pages'), int), - _query=query, - _path='tags/values', - _resource='values' - ) + """ + query = self._tag_list_constructor( + filters, + self._filterset_tags, + kw['filter_type'] if 'filter_type' in kw else None, + kw['sort'] if 'sort' in kw else None, + ) + return TagsIterator( + self._api, + _limit=self._check('limit', kw.get('limit', 1000), int), + _offset=self._check('offset', kw.get('offset', 0), int), + _pages_total=self._check('pages', kw.get('pages'), int), + _query=query, + _path='tags/values', + _resource='values', + ) def list_categories(self, *filters, **kw): - ''' + """ Retrieves a list of tag categories based off of the filters defined within the query. @@ -634,21 +745,25 @@ def list_categories(self, *filters, **kw): >>> for tag in tio.tags.list_categories( ... ('name', 'eq', 'Location')): ... pprint(tag) - ''' - query = self._tag_list_constructor(filters, self._filterset_categories, - kw['filter_type'] if 'filter_type' in kw else None, - kw['sort'] if 'sort' in kw else None) - return TagsIterator(self._api, - _limit=self._check('limit', kw.get('limit', 1000), int), - _offset=self._check('offset', kw.get('offset', 0), int), - _pages_total=self._check('pages', kw.get('pages'), int), - _query=query, - _path='tags/categories', - _resource='categories' - ) + """ + query = self._tag_list_constructor( + filters, + self._filterset_categories, + kw['filter_type'] if 'filter_type' in kw else None, + kw['sort'] if 'sort' in kw else None, + ) + return TagsIterator( + self._api, + _limit=self._check('limit', kw.get('limit', 1000), int), + _offset=self._check('offset', kw.get('offset', 0), int), + _pages_total=self._check('pages', kw.get('pages'), int), + _query=query, + _path='tags/categories', + _resource='categories', + ) def assign(self, assets, tags): - ''' + """ Assigns the tag category/value pairs defined to the assets defined. :devportal:`tags: assign tags ` @@ -667,17 +782,20 @@ def assign(self, assets, tags): >>> tio.tags.assign( ... assets=['00000000-0000-0000-0000-000000000000'], ... tags=['00000000-0000-0000-0000-000000000000']) - ''' + """ self._check('assets', assets, list) self._check('tags', tags, list) - return self._api.post('tags/assets/assignments', json={ - 'action': 'add', - 'assets': [self._check('asset', a, 'uuid') for a in assets], - 'tags': [self._check('tag', t, 'uuid') for t in tags], - }).json()['job_uuid'] + return self._api.post( + 'tags/assets/assignments', + json={ + 'action': 'add', + 'assets': [self._check('asset', a, 'uuid') for a in assets], + 'tags': [self._check('tag', t, 'uuid') for t in tags], + }, + ).json()['job_uuid'] def unassign(self, assets, tags): - ''' + """ Un-assigns the tag category/value pairs defined to the assets defined. :devportal:`tags: assign tags ` @@ -696,14 +814,17 @@ def unassign(self, assets, tags): >>> tio.tags.unassign( ... assets=['00000000-0000-0000-0000-000000000000'], ... tags=['00000000-0000-0000-0000-000000000000']) - ''' + """ self._check('assets', assets, list) self._check('tags', tags, list) - return self._api.post('tags/assets/assignments', json={ - 'action': 'remove', - 'assets': [self._check('asset', a, 'uuid') for a in assets], - 'tags': [self._check('tag', t, 'uuid') for t in tags], - }).json()['job_uuid'] + return self._api.post( + 'tags/assets/assignments', + json={ + 'action': 'remove', + 'assets': [self._check('asset', a, 'uuid') for a in assets], + 'tags': [self._check('tag', t, 'uuid') for t in tags], + }, + ).json()['job_uuid'] def get_tag_uuid(self, category, value): """ diff --git a/tenable/io/users.py b/tenable/io/users.py index 72dfb6fc2..b41edb02a 100644 --- a/tenable/io/users.py +++ b/tenable/io/users.py @@ -1,4 +1,4 @@ -''' +""" Users ===== @@ -10,17 +10,21 @@ .. rst-class:: hide-signature .. autoclass:: UsersAPI :members: -''' -from tenable.utils import dict_merge +""" + from tenable.io.base import TIOEndpoint +from tenable.utils import dict_merge, scrub + class UsersAPI(TIOEndpoint): - ''' + """ This will contain all methods related to Users - ''' - def create(self, username, password, permissions, - name=None, email=None, account_type=None): - ''' + """ + + def create( + self, username, password, permissions, name=None, email=None, account_type=None + ): + """ Create a new user. :devportal:`users: create ` @@ -53,7 +57,7 @@ def create(self, username, password, permissions, >>> user = tio.users.create('jdoe@company.com', 'password', 64, ... name='Jane Doe', email='jdoe@company.com') - ''' + """ payload = { 'username': self._check('username', username, str), 'password': self._check('password', password, str), @@ -69,7 +73,7 @@ def create(self, username, password, permissions, return self._api.post('users', json=payload).json() def delete(self, user_id): - ''' + """ Removes a user from Tenable Vulnerability Management. :devportal:`users: delete ` @@ -83,11 +87,11 @@ def delete(self, user_id): Examples: >>> tio.users.delete(1) - ''' + """ self._api.delete('users/{}'.format(self._check('user_id', user_id, int))) def details(self, user_id): - ''' + """ Retrieve the details of a user. :devportal:`users: details ` @@ -101,11 +105,11 @@ def details(self, user_id): Examples: >>> user = tio.users.details(1) - ''' - return self._api.get('users/{}'.format(self._check('user_id', user_id, int))).json() + """ + return self._api.get(f'users/{scrub(user_id)}').json() def edit(self, user_id, permissions=None, name=None, email=None, enabled=None): - ''' + """ Modify an existing user. :devportal:`users: edit ` @@ -128,12 +132,11 @@ def edit(self, user_id, permissions=None, name=None, email=None, enabled=None): Examples: >>> user = tio.users.edit(1, name='New Full Name') - ''' + """ payload = dict() if permissions: - payload['permissions'] = self._check('permissions', permissions, - int) + payload['permissions'] = self._check('permissions', permissions, int) if enabled is not None: payload['enabled'] = self._check('enabled', enabled, bool) if email: @@ -143,16 +146,19 @@ def edit(self, user_id, permissions=None, name=None, email=None, enabled=None): # Merge the data that we build with the payload with the user details. user = self.details(self._check('user_id', user_id, int)) - payload = dict_merge({ - 'permissions': user['permissions'], - 'enabled': user['enabled'], - 'email': user['email'], - 'name': user.get('name', None), - }, payload) - return self._api.put('users/{}'.format(user_id), json=payload).json() + payload = dict_merge( + { + 'permissions': user['permissions'], + 'enabled': user['enabled'], + 'email': user['email'], + 'name': user.get('name', None), + }, + payload, + ) + return self._api.put(f'users/{scrub(user_id)}').json() def enabled(self, user_id, enabled): - ''' + """ Enable the user account. :devportal:`users: enabled ` @@ -173,13 +179,14 @@ def enabled(self, user_id, enabled): Disable a user: >>> tio.users.enabled(1, False) - ''' - return self._api.put('users/{}/enabled'.format( - self._check('user_id', user_id, int)), json={ - 'enabled': self._check('enabled', enabled, bool)}).json() + """ + return self._api.put( + f'users/{scrub(user_id)}/enabled', + json={'enabled': self._check('enabled', enabled, bool)}, + ).json() def two_factor(self, user_id, email, sms, phone=None): - ''' + """ Configure two-factor authorization for a specific user. :devportal:`users: two-factor ` @@ -206,18 +213,17 @@ def two_factor(self, user_id, email, sms, phone=None): Enable SMS authorization for a user: >>> tio.users.two_factor(1, False, True, '9998887766') - ''' + """ payload = { 'email_enabled': self._check('email', email, bool), - 'sms_enabled': self._check('sms', sms, bool) + 'sms_enabled': self._check('sms', sms, bool), } if phone: payload['sms_phone'] = self._check('phone', phone, str) - self._api.put('users/{}/two-factor'.format( - self._check('user_id', user_id, int)), json=payload) + self._api.put(f'users/{scrub(user_id)}/two-factor', json=payload) def enable_two_factor(self, user_id, phone, password): - ''' + """ Enable phone-based two-factor authorization for a specific user. :devportal:`users: two-factor-enable ` @@ -233,15 +239,17 @@ def enable_two_factor(self, user_id, phone, password): Examples: >>> tio.users.enable_two_factor(1, '9998887766') - ''' - self._api.post('users/{}/two-factor/send-verification'.format( - self._check('user_id', user_id, int)), json={ + """ + self._api.post( + f'users/{scrub(user_id)}/two-factor/send-verification', + json={ 'sms_phone': self._check('phone', phone, str), - 'password': self._check('password', password, str) - }) + 'password': self._check('password', password, str), + }, + ) def verify_two_factor(self, user_id, code): - ''' + """ Send the verification code for two-factor authorization. :devportal:`users: two-factor-enable-verify ` @@ -255,13 +263,14 @@ def verify_two_factor(self, user_id, code): Examples: >>> tio.users.verify_two_factor(1, 'abc123') - ''' - self._api.post('users/{}/two-factor/verify-code'.format( - self._check('user_id', user_id, int)), json={ - 'verification_code': self._check('code', code, str)}) + """ + self._api.post( + f'users/{scrub(user_id)}/two-factor/verify-code', + json={'verification_code': self._check('code', code, str)}, + ) def impersonate(self, name): - ''' + """ Impersonate as a specific user. :devportal:`users: impersonate ` @@ -275,13 +284,13 @@ def impersonate(self, name): Examples: >>> tio.users.impersonate('jdoe@company.com') - ''' - self._api._session.headers.update({ - 'X-Impersonate': 'username={}'.format(self._check('name', name, str)) - }) + """ + self._api._session.headers.update( + {'X-Impersonate': 'username={}'.format(self._check('name', name, str))} + ) def list(self): - ''' + """ Retrieves a list of users. :devportal:`users: list ` @@ -293,11 +302,11 @@ def list(self): Examples: >>> for user in tio.users.list(): ... pprint(user) - ''' + """ return self._api.get('users').json()['users'] def change_password(self, user_id, old_password, new_password): - ''' + """ Change the password for a specific user. :devportal:`users: password ` @@ -313,14 +322,17 @@ def change_password(self, user_id, old_password, new_password): Examples: >>> tio.users.change_password(1, 'old_pass', 'new_pass') - ''' - self._api.put('users/{}/chpasswd'.format(self._check('user_id', user_id, int)), json={ - 'password': self._check('new_password', new_password, str), - 'current_password': self._check('old_password', old_password, str) - }) + """ + self._api.put( + f'users/{scrub(user_id)}/chpasswd', + json={ + 'password': self._check('new_password', new_password, str), + 'current_password': self._check('old_password', old_password, str), + }, + ) def gen_api_keys(self, user_id): - ''' + """ Generate the API keys for a specific user. :devportal:`users: keys ` @@ -334,12 +346,11 @@ def gen_api_keys(self, user_id): Examples: >>> keys = tio.users.gen_api_keys(1) - ''' - return self._api.put('users/{}/keys'.format( - self._check('user_id', user_id, int))).json() + """ + return self._api.put(f'users/{scrub(user_id)}/keys').json() def list_auths(self, user_id): - ''' + """ list user authorizations for accessing a Tenable Vulnerability Management instance. :devportal:`users: list-auths ` @@ -353,12 +364,13 @@ def list_auths(self, user_id): Examples: >>> auth = tio.users.list_auths(1) - ''' - return self._api.get('users/{}/authorizations'.format( - self._check('user_id', user_id, int))).json() + """ + return self._api.get(f'users/{scrub(user_id)}/authorizations').json() - def edit_auths(self, user_id, api_permitted=None, password_permitted=None, saml_permitted=None): - ''' + def edit_auths( + self, user_id, api_permitted=None, password_permitted=None, saml_permitted=None + ): + """ update user authorizations for accessing a Tenable Vulnerability Management instance. :devportal:`users: edit-auths ` @@ -379,19 +391,30 @@ def edit_auths(self, user_id, api_permitted=None, password_permitted=None, saml_ Examples: >>> tio.users.edit_auths(1, True, True, False) - ''' + """ # get current settings current = self.list_auths(self._check('user_id', user_id, int)) # update payload with new settings payload = { - 'api_permitted': self._check('api_permitted', api_permitted, bool, - default=current['api_permitted']), - 'password_permitted': self._check('password_permitted', password_permitted, bool, - default=current['password_permitted']), - 'saml_permitted': self._check('saml_permitted', saml_permitted, bool, - default=current['saml_permitted']) + 'api_permitted': self._check( + 'api_permitted', api_permitted, bool, default=current['api_permitted'] + ), + 'password_permitted': self._check( + 'password_permitted', + password_permitted, + bool, + default=current['password_permitted'], + ), + 'saml_permitted': self._check( + 'saml_permitted', + saml_permitted, + bool, + default=current['saml_permitted'], + ), } - return self._api.put('users/{}/authorizations'.format( - self._check('user_id', user_id, int)), json=payload) + return self._api.put( + f'users/{scrub(user_id)}/authorizations', + json=payload, + ) diff --git a/tenable/io/v3/__init__.py b/tenable/io/v3/__init__.py deleted file mode 100644 index 5fe57d509..000000000 --- a/tenable/io/v3/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Version3 API -============ - -The following sub-package allows for interaction with the Tenable Vulnerability Management - Version3API - -Methods available on ``tio.v3``: - -.. rst-class:: hide-signature -.. autoclass:: Version3API - :members: - -.. toctree:: - :hidden: - :glob: - - access_control -""" -import warnings -from tenable.base.endpoint import APIEndpoint -from tenable.io.access_control import AccessControlAPI - - -class Version3API(APIEndpoint): - """ - This will contain property for all resources/app under Tenable Vulnerability Management - V3. - """ - - @property - def access_control(self): - """ - The interface object for the - :doc:`Tenable Vulnerability Management v3 access control ` - """ - warnings.warn('This Method is deprecated and will be removed in a' - 'later release, please switch to using the' - '`access_control` method on the base TVM Object instead', - DeprecationWarning, - stacklevel=2 - ) - return AccessControlAPI(self._api) diff --git a/tenable/io/v3/base/__init__.py b/tenable/io/v3/base/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tenable/io/v3/base/endpoints/__init__.py b/tenable/io/v3/base/endpoints/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tenable/io/v3/base/endpoints/explore.py b/tenable/io/v3/base/endpoints/explore.py deleted file mode 100644 index e3544ac0d..000000000 --- a/tenable/io/v3/base/endpoints/explore.py +++ /dev/null @@ -1,130 +0,0 @@ -''' -Base explore class for V3 endpoints -''' -from enum import Enum -from typing import Union - -from requests import Response - -from tenable.base.endpoint import APIEndpoint -from tenable.io.v3.base.iterators.explore_iterator import (ExploreIterator, - SearchIterator) - -from tenable.io.v3.base.schema.explore.search import (SearchSchemaV3, - SortType) - - -class ExploreBaseEndpoint(APIEndpoint): - _conv_json = False - _sort_type = SortType - - def _search(self, - *, - resource: str, - api_path: str, - sort_type: Enum = _sort_type.default, - return_resp: bool = False, - iterator_cls: ExploreIterator = SearchIterator, - schema_cls: SearchSchemaV3 = SearchSchemaV3, - **kwargs - ) -> Union[Response, ExploreIterator]: - ''' - Initiate a search - - Args: - resource (str): - The json key to fetch the data from response - api_path (str): - API path for search endpoint - sort_type (enum): - Select format of sort expected by API. All the - supported formats are present in SortType Enumeration Class. - fields (list, optional): - The list of field names to return from the Tenable API. - Example: - >>> ['field1', 'field2'] - sort (list[tuple], optional): - sort is a list of tuples in the form of - ('FIELD', 'ORDER'). - It describes how to sort the data - that is to be returned. - Examples: - >>> [('field_name_1', 'asc'), - ... ('field_name_2', 'desc')] - filter (tuple, dict, optional): - A nestable filter object detailing how to filter the results - down to the desired subset. - Examples: - >>> ('or', ('and', ('test', 'oper', '1'), - ... ('test', 'oper', '2') - ... ), - ... 'and', ('test', 'oper', 3) - ... ) - >>> { - ... 'or': [{ - ... 'and': [{ - ... 'value': '1', - ... 'operator': 'oper', - ... 'property': '1' - ... }, - ... { - ... 'value': '2', - ... 'operator': 'oper', - ... 'property': '2' - ... } - ... ] - ... }], - ... 'and': [{ - ... 'value': '3', - ... 'operator': 'oper', - ... 'property': 3 - ... }] - ... } - limit (int, optional): - Number of objects to be returned in each request. - Default and maximum limit is 200. - next (str, optional): - The pagination token to use when requesting the next page of - results. This token is presented in the previous response. - return_resp (bool, optional): - If set to true, will override the default behavior to return - a requests.Response Object to the user. - return_csv (bool, optional): - If set to true, it will return the CSV response or - iterable (based on return_resp flag). Iterator returns all - rows in text/csv format for each call with row headers. - iterator_cls: - If specified, will override the default iterator class that - will be used for instantiating the iterator. - schema_cls: - If specified, will override the default Search schema class - that will be used for validation. - - :Returns: - - Iterable: - The iterable that handles the pagination for the job. - - requests.Response: - If ``return_resp`` is set to ``True``, then a response - object is returned instead of an iterable. - - ''' - schema = schema_cls( - context={'sort_type': sort_type}) - return_csv = kwargs.pop('return_csv', False) - payload = schema.dump(schema.load(kwargs)) - - if return_resp: - headers = {} - if return_csv: - headers = {'Accept': 'text/csv'} - return self._api.post( - api_path, - json=payload, - headers=headers - ) - return iterator_cls( - self._api, - _path=api_path, - _resource=resource, - _payload=payload - ) diff --git a/tenable/io/v3/base/iterators/__init__.py b/tenable/io/v3/base/iterators/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tenable/io/v3/base/iterators/explore_iterator.py b/tenable/io/v3/base/iterators/explore_iterator.py deleted file mode 100644 index 20c5dc20a..000000000 --- a/tenable/io/v3/base/iterators/explore_iterator.py +++ /dev/null @@ -1,101 +0,0 @@ -''' -Iterator for search V3 endpoints -''' -from copy import copy -from typing import Dict - -from requests import Response - -from tenable.io.v3.base.iterators.iterator import APIResultIterator - - -class ExploreIterator(APIResultIterator): - ''' - Base Iterator for explore V3 endpoints - ''' - _path: str = None - _next_token: str = None - _resource: str = None - _payload: Dict = {} - _headers: Dict = {} - - def _construct_headers(self) -> Dict: - ''' - Constructs the headers for API calls - ''' - return copy(self._headers) - - def _construct_payload(self) -> Dict: - ''' - Constructs the payload for the API - ''' - payload = copy(self._payload) - if self._next_token: - payload['next'] = self._next_token - return payload - - def _process_response(self, response: Response) -> None: - ''' - Processes the API response - ''' - pass - - def _get_page(self) -> None: - ''' - Request the next page of data - ''' - payload = self._construct_payload() - headers = self._construct_headers() - - # This is to stop the iteration in case of csv_iterator - # for infinite loop. - if self.num_pages >= 1 and self._next_token is None: - raise StopIteration() - resp = self._api.post(self._path, - json=payload, - headers=headers - ) - self._next_token = resp.headers.get('X-Continuation-Token') - self._process_response(resp) - - -class SearchIterator(ExploreIterator): - ''' - Search Iterator for explore V3 endpoints - ''' - - def _process_response(self, response: Response) -> None: - ''' - Process the API Response - ''' - body = response.json() - # Pagination value can be null in JSON response, we need to make sure - # a dict is returned - pagination = body.get('pagination') or {} - self.page = body[self._resource] - self.total = pagination.get('total') - self._next_token = pagination.get('next') - - -class CSVChunkIterator(ExploreIterator): - ''' - CSV Iterator for explore V3 endpoints - ''' - _headers = {'Accept': 'text/csv'} - row_headers: str = None - - def _process_response(self, response: Response) -> None: - ''' - Process the API Response - ''' - if not self.row_headers: - self.row_headers, _, data = response.text.partition('\n') - self.page = [data] - else: - self.page = [response.text] - - def __getitem__(self, key: int) -> str: - ''' - Returns the specified item - ''' - return '\n'.join([self.row_headers, self.page[key]]) diff --git a/tenable/io/v3/base/iterators/iterator.py b/tenable/io/v3/base/iterators/iterator.py deleted file mode 100644 index 29188d26a..000000000 --- a/tenable/io/v3/base/iterators/iterator.py +++ /dev/null @@ -1,16 +0,0 @@ -''' -Version 3 classes -================= -This class is a iterator for version 3 API call - -.. autoclass:: APIResultIterator - :members: -''' -from restfly.iterator import APIIterator - - -class APIResultIterator(APIIterator): - ''' - Iterator class for version 3 API - ''' - pass diff --git a/tenable/io/v3/base/schema/__init__.py b/tenable/io/v3/base/schema/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tenable/io/v3/base/schema/explore/__init__.py b/tenable/io/v3/base/schema/explore/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tenable/io/v3/base/schema/explore/filters.py b/tenable/io/v3/base/schema/explore/filters.py deleted file mode 100644 index c16d8a05c..000000000 --- a/tenable/io/v3/base/schema/explore/filters.py +++ /dev/null @@ -1,148 +0,0 @@ -''' -Base explore filter schema for V3 endpoints -''' -from typing import Dict, Tuple, Union - -from marshmallow import Schema, ValidationError, fields -from marshmallow.decorators import pre_load - - -class FilterSchemaV3(Schema): - ''' - Schema supporting both the Filter and FilterGroups for V3 endpoints - ''' - - property = fields.Str() - operator = fields.Str() - value = fields.Raw() - and_ = fields.List(fields.Nested('FilterSchemaV3'), data_key='and') - or_ = fields.List(fields.Nested('FilterSchemaV3'), data_key='or') - - @pre_load(pass_many=False) - def validate_and_transform(self, data, **kwargs): # noqa: PLW0613 - ''' - Handles schema validation and data transform based on the data presented. - ''' - if ( # noqa: PLR1705 - isinstance(data, dict) and ('and' in data or 'or' in data) - ) or (isinstance(data, tuple) and data[0] in ['and', 'or']): - # We need to check to see if the data dictionary - # is a group of filters. To do so we will check to see - # if the following conditions are met: - # - # 1. The data obj is a dictionary and has either an 'and' key - # or an 'or' key. - # 2. The data obj is a tuple and the first element is a string - # with a value of 'and' or 'or' - # - # If either condition is met, then we will pass the data obj - # to the filter_group_transform method for validation - # and transformation - return self.filter_group_transform(data) - elif ( - isinstance(data, dict) - and ( - 'property' in data # noqa: PLR0916 - and 'operator' in data - and 'value' in data - ) - ) or (isinstance(data, tuple) and len(data) == 3): - return self.filter_tuple_expansion(data) - else: - raise ValidationError('Invalid Filter definition') - - def filter_group_transform( - self, data: Union[Tuple, Dict]) -> Dict: # noqa: PLR0201 - ''' - Handles expanding a tuple definition of a filter group into the - dictionary equivalent. - - Example: - - >>> f = ('or', ('and', ('test', 'oper', '1'), - ... ('test', 'oper', '2') - ... ), - ... 'and', ('test', 'oper', 3) - ... ) - >>> filter.dump(filter.load(f)) - {'or': [ - {'and': [ - {'value': '1', 'operator': 'oper', 'property': '1'}, - {'value': '2', 'operator': 'oper', 'property': '2'} - ] - }], - 'and': [ - {'value': '3', 'operator': 'oper', 'property': 3} - ] - } - ''' - if isinstance(data, tuple): - # if the data object is a tuple, then we will need to pass it to - # the tuple -> dict transformer. - return self.filter_group_tuple_expansion(data) - else: - # pass the data object right through if it's not a tuple and rely - # on the schema to validate the data object. - return data - - def filter_group_tuple_expansion(self, data: Tuple) -> Dict: - ''' - Transforms a logical group tuple into a logical group dictionary. - - ''' - resp = {} - oper = None - errors = {} - for element in data: - # If the element is either 'and' or 'or' and doesn't already exist - # within the response dict, then we will store the operator and - # create the list within the response to store this logical - # grouping. - if ( - isinstance(element, str) - and element in ['and', 'or'] - and not resp.get(element) - ): - resp[element] = [] - oper = element - # if the element is a logical condition that we have already seen - # before, we should assume that this is a malformed tuple and log - # the error into the errors dict. - elif ( - isinstance(element, str) - and element in ['and', 'or'] - and resp.get(element) - ): - errors[element] = [ - ( - f'attempted to use logical condition {element}\ - multiple times' - ) - ] - # If there is no stored operator, when we will log a 'NoneOper' - # validation error. - elif oper is None: - errors['NoneOper'] = ['No valid logical condition detected'] - # If none of the above conditions have been met, then we can safely - # assume that the element is a filter and we should append it to - # the current logical grouping. - else: - resp[oper].append(element) - if errors: - raise ValidationError(errors) - return resp - - def filter_tuple_expansion(self, data) -> Dict: # noqa: PLR0201 - ''' - Handles expanding a tuple definition of a filter into the dictionary - equivalent. - - Example: - - >>> f = ('filter', 'oper', 'value') - >>> filter.dump(filter.load(f)) - {'property': 'filter', 'operator': 'oper', 'value': 'value'} - ''' - if isinstance(data, tuple): - return {'property': data[0], 'operator': data[1], 'value': data[2]} - return data diff --git a/tenable/io/v3/base/schema/explore/search.py b/tenable/io/v3/base/schema/explore/search.py deleted file mode 100644 index 0f11940d6..000000000 --- a/tenable/io/v3/base/schema/explore/search.py +++ /dev/null @@ -1,62 +0,0 @@ -''' -Base explore search schema for V3 endpoints -''' -from enum import Enum - -from marshmallow import Schema, fields, post_dump, pre_load -from marshmallow import validate as v - -from tenable.io.v3.base.schema.explore.filters import FilterSchemaV3 - - -class SortType(Enum): - ''' - Enum class for different types of sort - ''' - default = 'Sort format: {FIELD:ORDER}' - property_based = 'Sort format: {"property": FIELD, "order": ORDER}' - name_based = 'Sort format: {"name": FIELD, "order": ORDER}' - - -class SortSchemaV3(Schema): - ''' - Schema for the sorting sub-object - ''' - property = fields.Str() - order = fields.Str(validate=v.OneOf(['asc', 'desc'])) - - @pre_load(pass_many=True) - def transform_data(self, data, **kwargs): - if isinstance(data, tuple) and len(data) == 2: - property = data[0] - order = data[1] - return dict(property=property, order=order) - return data - - @post_dump(pass_many=True) - def transform_request_data(self, data, **kwargs): - if ( - not self.context.get("sort_type") - or self.context.get("sort_type") == SortType.default - ): - return { - data['property']: data['order'] - } - elif self.context.get('sort_type') == SortType.name_based: - return { - 'name': data['property'], - 'order': data['order'] - } - elif self.context.get('sort_type') == SortType.property_based: - return data - - -class SearchSchemaV3(Schema): - ''' - Schema supporting the search request - ''' - fields_ = fields.List(fields.Str(), allow_none=True, data_key='fields') - filter = fields.Nested(FilterSchemaV3, allow_none=True) - limit = fields.Int(dump_default=200) - next = fields.Str(allow_none=True) - sort = fields.List(fields.Nested(SortSchemaV3), allow_none=True) diff --git a/tenable/io/was/api.py b/tenable/io/was/api.py index dd79818f2..dc40a13e2 100644 --- a/tenable/io/was/api.py +++ b/tenable/io/was/api.py @@ -12,9 +12,11 @@ :members: """ +from typing import Any, Dict, Tuple + from tenable.io.base import TIOEndpoint, TIOIterator from tenable.io.was.iterator import WasIterator -from typing import Any, Dict, Tuple +from tenable.utils import scrub class WasAPI(TIOEndpoint): @@ -28,11 +30,13 @@ def export(self, **kwargs) -> WasIterator: Args: single_filter (tuple): - A single filter to apply to the scan configuration search. This is a tuple with three elements - - field, operator, and value in that order. - and_filter (list): An array of filters that must all be satisfied. This is a list of tuples with three elements - - field, operator, and value in that order. - or_filter (list): An array of filters where at least one must be satisfied. This is a list of tuples with three elements - + A single filter to apply to the scan configuration search. This is a + tuple with three elements - field, operator, and value in that order. + and_filter (list): An array of filters that must all be satisfied. This + is a list of tuples with three elements - field, operator, and value + in that order. + or_filter (list): An array of filters where at least one must be + satisfied. This is a list of tuples with three elements - field, operator, and value in that order. Returns: @@ -56,29 +60,42 @@ def export(self, **kwargs) -> WasIterator: # Get scan configuration iterator. scan_config = self._search_scan_configurations(**kwargs) - # Iterate through the scan configs and collect the parent scan IDs and the finalized_at param. - # This finalized_at property belonging to the parent will be passed down to its children's findings. - parent_scan_ids_with_finalized_at = [_parent_id_with_finalized_at(sc) for sc in scan_config if sc] + # Iterate through the scan configs and collect the parent scan IDs and the + # finalized_at param. This finalized_at property belonging to the parent will + # be passed down to its children's findings. + parent_scan_ids_with_finalized_at = [ + _parent_id_with_finalized_at(sc) for sc in scan_config if sc + ] # initialize parent_scan_ids_with_finalized_at if it's empty. if not parent_scan_ids_with_finalized_at: parent_scan_ids_with_finalized_at = [] - self._log.debug(f"We have {len(parent_scan_ids_with_finalized_at)} parent scan ID(s) to process.") + self._log.debug( + f'We have {len(parent_scan_ids_with_finalized_at)} parent scan ID(s) to process.' + ) # Fetch the target scans info for all the above parent scan IDs, and flatten it. # We need to flatten because, each parent ID will have multiple target scans. - self._log.debug(f"Fetching Target scan IDs for {len(parent_scan_ids_with_finalized_at)} parent scan ID(s)") - target_scans = [scan for p in parent_scan_ids_with_finalized_at for scan in - self._get_target_scan_ids_for_parent(p)] + self._log.debug( + f'Fetching Target scan IDs for {len(parent_scan_ids_with_finalized_at)} parent scan ID(s)' + ) + target_scans = [ + scan + for p in parent_scan_ids_with_finalized_at + for scan in self._get_target_scan_ids_for_parent(p) + ] # Iterate through the target scans info and collect the target scan IDs. - target_scan_ids_with_parent_finalized_at = [_target_id_with_parent_finalized_at(ts) for ts in target_scans if ts] - self._log.debug(f"We have {len(target_scan_ids_with_parent_finalized_at)} target scan(s) to process.") + target_scan_ids_with_parent_finalized_at = [ + _target_id_with_parent_finalized_at(ts) for ts in target_scans if ts + ] + self._log.debug( + f'We have {len(target_scan_ids_with_parent_finalized_at)} target scan(s) to process.' + ) return WasIterator( - api=self._api.was, - target_scan_ids=target_scan_ids_with_parent_finalized_at + api=self._api.was, target_scan_ids=target_scan_ids_with_parent_finalized_at ) def download_scan_report(self, scan_uuid: str) -> Dict: @@ -90,10 +107,8 @@ def download_scan_report(self, scan_uuid: str) -> Dict: UUID of the scan whose report to download. """ return self._api.get( - path=f"was/v2/scans/{scan_uuid}/report", - headers={ - "Content-Type": "application/json" - } + path=f'was/v2/scans/{scrub(scan_uuid)}/report', + headers={'Content-Type': 'application/json'}, ).json() def _search_scan_configurations(self, **kwargs) -> TIOIterator: @@ -103,19 +118,25 @@ def _search_scan_configurations(self, **kwargs) -> TIOIterator: payload = dict() # Either single_filter should be passed alone. Or, any or all of these [and_filter, or_filter] can be passed. - if "single_filter" in kwargs and (("and_filter" in kwargs) or ("or_filter" in kwargs)): - raise AttributeError("single_filter cannot be passed alongside and_filter or or_filter.") + if 'single_filter' in kwargs and ( + ('and_filter' in kwargs) or ('or_filter' in kwargs) + ): + raise AttributeError( + 'single_filter cannot be passed alongside and_filter or or_filter.' + ) - if "single_filter" in kwargs: - payload = _tuple_to_filter(kwargs["single_filter"]) + if 'single_filter' in kwargs: + payload = _tuple_to_filter(kwargs['single_filter']) - if "and_filter" in kwargs: - payload["AND"] = [_tuple_to_filter(t) for t in kwargs["and_filter"]] + if 'and_filter' in kwargs: + payload['AND'] = [_tuple_to_filter(t) for t in kwargs['and_filter']] - if "or_filter" in kwargs: - payload["OR"] = [_tuple_to_filter(t) for t in kwargs["or_filter"]] + if 'or_filter' in kwargs: + payload['OR'] = [_tuple_to_filter(t) for t in kwargs['or_filter']] - self._log.debug(f"Fetching the scan configuration information with filters: {payload} ...") + self._log.debug( + f'Fetching the scan configuration information with filters: {payload} ...' + ) return TIOIterator( self._api, @@ -123,20 +144,20 @@ def _search_scan_configurations(self, **kwargs) -> TIOIterator: _offset=self._check('offset', 0, int), _query=dict(), _path='was/v2/configs/search', - _method="POST", + _method='POST', _payload=payload, - _resource='items' + _resource='items', ) - def _get_target_scan_ids_for_parent(self, parent: dict) -> Dict: + def _get_target_scan_ids_for_parent(self, parent: dict) -> list[dict[str, Any]]: """ Returns the vulns by target scans of the given parent scan ID. """ # This method does not have an iterator and is not public as the API it invokes has not been publicly documented. # However, the API is in use in the Tenable Vulnerability Management UI. - parent_scan_id = parent["parent_scan_id"] - parent_finalized_at = parent["parent_finalized_at"] + parent_scan_id = parent['parent_scan_id'] + parent_finalized_at = parent['parent_finalized_at'] offset = 0 limit = 200 @@ -147,15 +168,15 @@ def _get_target_scan_ids_for_parent(self, parent: dict) -> Dict: while True: # Fetch the page response = self._api.post( - path=f"was/v2/scans/{parent_scan_id}/vulnerabilities/by-targets/search?limit={limit}&offset={offset}" + path=f'was/v2/scans/{scrub(parent_scan_id)}/vulnerabilities/by-targets/search?limit={limit}&offset={offset}' ).json() # Collect the items; flatten; and write to the flattened list (extend). - items_in_response = response["items"] - items = [{ - "items": item, - "parent_finalized_at": parent_finalized_at - } for item in items_in_response] + items_in_response = response['items'] + items = [ + {'items': item, 'parent_finalized_at': parent_finalized_at} + for item in items_in_response + ] flattened_list.extend(items) @@ -163,10 +184,14 @@ def _get_target_scan_ids_for_parent(self, parent: dict) -> Dict: offset += limit if not items_in_response: - self._log.debug(f"Stopping the iteration as we encountered an empty response from the API.") + self._log.debug( + 'Stopping the iteration as we encountered an empty response from the API.' + ) break - self._log.debug(f"Parent ID: {parent_scan_id} has {len(flattened_list)} target ID(s).") + self._log.debug( + f'Parent ID: {parent_scan_id} has {len(flattened_list)} target ID(s).' + ) return flattened_list @@ -175,18 +200,18 @@ def _tuple_to_filter(t: Tuple[str, str, Any]) -> Dict: """ Accepts a tuple with three elements, and returns a filter object. """ - return {"field": t[0], "operator": t[1], "value": t[2]} + return {'field': t[0], 'operator': t[1], 'value': t[2]} def _parent_id_with_finalized_at(scan_config: dict): return { - "parent_scan_id": scan_config["last_scan"]["scan_id"], - "parent_finalized_at": scan_config["last_scan"]["finalized_at"] + 'parent_scan_id': scan_config['last_scan']['scan_id'], + 'parent_finalized_at': scan_config['last_scan']['finalized_at'], } def _target_id_with_parent_finalized_at(target_scan: dict): return { - "target_scan_id": target_scan["items"]["scan"]["scan_id"], - "parent_finalized_at": target_scan["parent_finalized_at"] + 'target_scan_id': target_scan['items']['scan']['scan_id'], + 'parent_finalized_at': target_scan['parent_finalized_at'], } diff --git a/tenable/io/was/iterator.py b/tenable/io/was/iterator.py index dd12f5029..60326e974 100644 --- a/tenable/io/was/iterator.py +++ b/tenable/io/was/iterator.py @@ -1,5 +1,5 @@ from typing import Any, List, Dict -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator class WasIterator(APIIterator): diff --git a/tenable/io/workbenches.py b/tenable/io/workbenches.py index f8760a41d..ff1a4d9a7 100644 --- a/tenable/io/workbenches.py +++ b/tenable/io/workbenches.py @@ -1,4 +1,4 @@ -''' +""" Workbenches =========== @@ -16,16 +16,19 @@ .. rst-class:: hide-signature .. autoclass:: WorkbenchesAPI :members: -''' +""" + from io import BytesIO -from .base import TIOEndpoint + from tenable.errors import UnexpectedValueError +from tenable.utils import scrub + +from .base import TIOEndpoint class WorkbenchesAPI(TIOEndpoint): def _workbench_query(self, filters, kw, filterdefs): - ''' - ''' + """ """ # Initiate the query dictionary with the filters parser. query = self._parse_filters(filters, filterdefs) @@ -39,14 +42,14 @@ def _workbench_query(self, filters, kw, filterdefs): # The scans & workbenches endpoints use a serialized JSON format for # query parameters, hence the x.y notation. query['filter.search_type'] = self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']) + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ) # Return the query to the caller return query - def assets(self, *filters, **kw): - ''' + """ The assets workbench allows for filtering and interactively querying the asset data stored within Tenable Vulnerability Management. There are a wide variety of filtering options available to find specific pieces of data. @@ -88,10 +91,11 @@ def assets(self, *filters, **kw): >>> for asset in tio.workbenches.assets( ... ('operating_system', 'match', 'Windows')): ... pprint(asset) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_asset_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_asset_filters() + ) # If all_fields is set to true or is unspecified, then we will set the # all_fields parameter to "full". @@ -104,7 +108,7 @@ def assets(self, *filters, **kw): return self._api.get('workbenches/assets', params=query).json()['assets'] def asset_activity(self, uuid): - ''' + """ Query for the asset activity (when was the asset was seen, were there changes, etc.). @@ -121,12 +125,13 @@ def asset_activity(self, uuid): >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> for entry in tio.workbenches.asset_activity(asset_id): ... pprint(entry) - ''' - return self._api.get('workbenches/assets/{}/activity'.format( - self._check('uuid', uuid, 'uuid'))).json()['activity'] + """ + return self._api.get(f'workbenches/assets/{scrub(uuid)}/activity').json()[ + 'activity' + ] def asset_info(self, uuid, all_fields=True): - ''' + """ Query for the information for a specific asset within the asset workbench. @@ -145,7 +150,7 @@ def asset_info(self, uuid, all_fields=True): Examples: >>> asset = tio.workbenches.asset_info('00000000-0000-0000-0000-000000000000') - ''' + """ query = {'all_fields': 'full'} if not self._check('all_fields', all_fields, bool): @@ -154,13 +159,15 @@ def asset_info(self, uuid, all_fields=True): # query dictionary. The documentation states that the existence of # the parameter is what triggers the expanded dataset, which we # are returning by default. - del(query['all_fields']) + del query['all_fields'] - return self._api.get('workbenches/assets/{}/info'.format( - self._check('uuid', uuid, 'uuid')), params=query).json()['info'] + return self._api.get( + f'workbenches/assets/{scrub(uuid)}/info', + params=query, + ).json()['info'] def asset_vulns(self, uuid, *filters, **kw): - ''' + """ Return the vulnerabilities for a specific asset. :devportal:`workbenches: asset-vulnerabilities ` @@ -190,17 +197,19 @@ def asset_vulns(self, uuid, *filters, **kw): >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> for vuln in tio.workbenches.asset_vulns(asset_id): ... pprint(vuln) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) return self._api.get( - 'workbenches/assets/{}/vulnerabilities'.format( - self._check('uuid', uuid, 'uuid')), params=query).json()['vulnerabilities'] + f'workbenches/assets/{scrub(uuid)}/vulnerabilities', + params=query, + ).json()['vulnerabilities'] def asset_vuln_info(self, uuid, plugin_id, *filters, **kw): - ''' + """ Retrieves the vulnerability information for a specific plugin on a specific asset within Tenable Vulnerability Management. @@ -233,18 +242,19 @@ def asset_vuln_info(self, uuid, plugin_id, *filters, **kw): >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> vuln = tio.workbenches.asset_vuln_info(asset_id, 19506) >>> pprint(vuln) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) return self._api.get( - 'workbenches/assets/{}/vulnerabilities/{}/info'.format( - self._check('uuid', uuid, 'uuid'), - self._check('plugin_id', plugin_id, int)), params=query).json()['info'] + f'workbenches/assets/{scrub(uuid)}/vulnerabilities/{scrub(plugin_id)}/info', + params=query, + ).json()['info'] def asset_vuln_output(self, uuid, plugin_id, *filters, **kw): - ''' + """ Retrieves the vulnerability output for a specific vulnerability on a specific asset within Tenable Vulnerability Management. @@ -277,18 +287,19 @@ def asset_vuln_output(self, uuid, plugin_id, *filters, **kw): >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> output = tio.workbenches.asset_vuln_output(asset_id, 19506) >>> pprint(output) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) return self._api.get( - 'workbenches/assets/{}/vulnerabilities/{}/outputs'.format( - self._check('uuid', uuid, 'uuid'), - self._check('plugin_id', plugin_id, int)), params=query).json()['outputs'] + f'workbenches/assets/{scrub(uuid)}/vulnerabilities/{scrub(plugin_id)}/outputs', + params=query, + ).json()['outputs'] def asset_delete(self, asset_uuid): - ''' + """ Deletes the asset. :devportal:`workbenches: asset-delete ` @@ -302,12 +313,11 @@ def asset_delete(self, asset_uuid): Examples: >>> asset_id = '00000000-0000-0000-0000-000000000000' >>> tio.workbenches.asset_delete(asset_id) - ''' - self._api.delete('workbenches/assets/{}'.format( - self._check('asset_uuid', asset_uuid, 'uuid'))) + """ + self._api.delete(f'workbenches/assets/{scrub(asset_uuid)}') def vuln_assets(self, *filters, **kw): - ''' + """ Retrieve assets based on the vulnerability data. :devportal:`workbenches: assets-vulnerabilities ` @@ -334,16 +344,18 @@ def vuln_assets(self, *filters, **kw): Examples: >>> for asset in tio.workbenches.vuln_assets(): ... pprint(asset) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) - return self._api.get( - 'workbenches/assets/vulnerabilities', params=query).json()['assets'] + return self._api.get('workbenches/assets/vulnerabilities', params=query).json()[ + 'assets' + ] def export(self, *filters, **kw): - ''' + """ Export data from the vulnerability workbench. These exports can be in a number of different formats, however the defaults are set to export a Nessusv2 report. @@ -392,30 +404,33 @@ def export(self, *filters, **kw): Examples: >>> with open('example.nessus', 'wb') as exportobj: ... tio.workbenches.export(fobj=exportobj) - ''' + """ # initiate the payload and parameters dictionaries. - params = self._parse_filters(filters, - self._api.filters.workbench_vuln_filters()) + params = self._parse_filters( + filters, self._api.filters.workbench_vuln_filters() + ) params['report'] = 'vulnerabilities' params['chapter'] = 'vuln_by_asset' params['format'] = 'nessus' if 'plugin_id' in kw: - params['plugin_id'] = self._check( - 'plugin_id', kw['plugin_id'], int) + params['plugin_id'] = self._check('plugin_id', kw['plugin_id'], int) if 'asset_uuid' in kw: - params['asset_id'] = self._check( - 'asset_uuid', kw['asset_uuid'], 'uuid') + params['asset_id'] = self._check('asset_uuid', kw['asset_uuid'], 'uuid') if 'format' in kw: - params['format'] = self._check('format', kw['format'], str, + params['format'] = self._check( + 'format', + kw['format'], + str, default='nessus', - choices=[ - 'nessus', 'csv', 'html', 'pdf' - ]) - if kw['format'] not in ['nessus',]: + choices=['nessus', 'csv', 'html', 'pdf'], + ) + if kw['format'] not in [ + 'nessus', + ]: # The chapters are sent to us in a list, and we need to collapse # that down to a comma-delimited string. Note that if the nessus # format is specified, we must use the vuln_by_asset report, so @@ -424,7 +439,10 @@ def export(self, *filters, **kw): raise UnexpectedValueError('no chapters were specified') else: params['chapter'] = ';'.join( - self._check('chapters', kw['chapters'], list, + self._check( + 'chapters', + kw['chapters'], + list, default='vuln_by_asset', choices=[ 'diff', @@ -433,11 +451,14 @@ def export(self, *filters, **kw): 'vuln_by_plugin', 'vuln_hosts_summary', 'vuln_by_asset', - ])) + ], + ) + ) if 'filter_type' in kw: params['filter.search_type'] = self._check( - 'filter_type', kw['filter_type'], str, choices=['and', 'or']) + 'filter_type', kw['filter_type'], str, choices=['and', 'or'] + ) # Now we need to set the FileObject. If one was passed to us, then lets # just use that, otherwise we will need to instantiate a BytesIO object @@ -449,21 +470,19 @@ def export(self, *filters, **kw): # The first thing that we need to do is make the request and get the # File id for the job. - fid = self._api.get('workbenches/export', - params=params).json()['file'] + fid = self._api.get('workbenches/export', params=params).json()['file'] self._api._log.debug('Initiated workbench export {}'.format(fid)) # Next we will wait for the state of the export request to become # ready. We will query the API every half a second until we get the # response we're looking for. self._wait_for_download( - 'workbenches/export/{}/status'.format(fid), - 'workbenches', 'export', fid) + f'workbenches/export/{scrub(fid)}/status', 'workbenches', 'export', fid + ) # Now that the status has reported back as "ready", we can actually # download the file. - resp = self._api.get('workbenches/export/{}/download'.format( - fid), stream=True) + resp = self._api.get(f'workbenches/export/{scrub(fid)}/download', stream=True) # Lets stream the file into the file-like object... for chunk in resp.iter_content(chunk_size=1024): @@ -474,7 +493,7 @@ def export(self, *filters, **kw): return fobj def vulns(self, *filters, **kw): - ''' + """ The vulnerability workbench allows for filtering and interactively querying the vulnerability data stored within Tenable Vulnerability Management. There are a wide variety of filtering options available to find specific pieces @@ -510,26 +529,34 @@ def vulns(self, *filters, **kw): Returns: :obj:`dict`: Vulnerability info resource - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) - if 'authenticated' in kw and self._check('authenticated', kw['authenticated'], bool): + if 'authenticated' in kw and self._check( + 'authenticated', kw['authenticated'], bool + ): query['authenticated'] = True if 'exploitable' in kw and self._check('exploitable', kw['exploitable'], bool): query['exploitable'] = True if 'resolvable' in kw and self._check('resolvable', kw['resolvable'], bool): query['resolvable'] = True - if 'severity' in kw and self._check('severity', kw['severity'], str, - choices=['critical', 'high', 'medium', 'low']): + if 'severity' in kw and self._check( + 'severity', + kw['severity'], + str, + choices=['critical', 'high', 'medium', 'low'], + ): query['severity'] = kw['severity'] - return self._api.get( - 'workbenches/vulnerabilities', params=query).json()['vulnerabilities'] + return self._api.get('workbenches/vulnerabilities', params=query).json()[ + 'vulnerabilities' + ] def vuln_info(self, plugin_id, *filters, **kw): - ''' + """ Retrieve the vulnerability information for a specific vulnerability. :devportal:`workbenches: vulnerability-info ` @@ -556,17 +583,18 @@ def vuln_info(self, plugin_id, *filters, **kw): Examples: >>> info = tio.workbenches.vuln_info(19506) >>> pprint(info) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) return self._api.get( - 'workbenches/vulnerabilities/{}/info'.format( - self._check('plugin_id', plugin_id, int)), params=query).json()['info'] + f'workbenches/vulnerabilities/{scrub(plugin_id)}/info', params=query + ).json()['info'] def vuln_outputs(self, plugin_id, *filters, **kw): - ''' + """ Retrieve the vulnerability output for a given vulnerability. :devportal:`workbenches: vulnerability-output ` @@ -593,11 +621,12 @@ def vuln_outputs(self, plugin_id, *filters, **kw): Examples: >>> outputs = tio.workbenches.vuln_outputs(19506) >>> pprint(outputs) - ''' + """ # Call the query builder to handle construction - query = self._workbench_query(filters, kw, - self._api.filters.workbench_vuln_filters()) + query = self._workbench_query( + filters, kw, self._api.filters.workbench_vuln_filters() + ) return self._api.get( - 'workbenches/vulnerabilities/{}/outputs'.format( - self._check('plugin_id', plugin_id, int)), params=query).json()['outputs'] + f'workbenches/vulnerabilities/{scrub(plugin_id)}/outputs', params=query + ).json()['outputs'] diff --git a/tenable/nessus/iterators/pagination.py b/tenable/nessus/iterators/pagination.py index 228b59734..ef7b6c0b5 100644 --- a/tenable/nessus/iterators/pagination.py +++ b/tenable/nessus/iterators/pagination.py @@ -1,5 +1,5 @@ from copy import copy -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator class PaginationIterator(APIIterator): diff --git a/tenable/nessus/iterators/plugins.py b/tenable/nessus/iterators/plugins.py index d278c6a29..9c2157c6b 100644 --- a/tenable/nessus/iterators/plugins.py +++ b/tenable/nessus/iterators/plugins.py @@ -1,6 +1,5 @@ from typing import Dict, List -from copy import copy -from restfly.iterator import APIIterator +from tenable.base._restfly_v1 import APIIterator class PluginIterator(APIIterator): diff --git a/tenable/nessus/mail.py b/tenable/nessus/mail.py index 506c3fa04..d66a4bc6e 100644 --- a/tenable/nessus/mail.py +++ b/tenable/nessus/mail.py @@ -12,7 +12,7 @@ from typing import Dict, Optional from typing_extensions import Literal from tenable.base.endpoint import APIEndpoint -from restfly.utils import dict_clean, dict_merge +from tenable.utils import dict_clean, dict_merge class MailAPI(APIEndpoint): diff --git a/tenable/nessus/permissions.py b/tenable/nessus/permissions.py index ddd2c58b8..aca8634b4 100644 --- a/tenable/nessus/permissions.py +++ b/tenable/nessus/permissions.py @@ -9,7 +9,7 @@ .. autoclass:: PermissionsAPI :members: ''' -from typing import Dict, Optional, List +from typing import Dict, List from typing_extensions import Literal from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/plugin_rules.py b/tenable/nessus/plugin_rules.py index 5582aeb80..f198a690e 100644 --- a/tenable/nessus/plugin_rules.py +++ b/tenable/nessus/plugin_rules.py @@ -11,9 +11,8 @@ ''' from typing import List, Dict, Optional from typing_extensions import Literal -from restfly.utils import dict_clean, dict_merge +from tenable.utils import dict_clean, dict_merge from tenable.base.endpoint import APIEndpoint -from .iterators.plugins import PluginIterator class PluginRulesAPI(APIEndpoint): diff --git a/tenable/nessus/proxy.py b/tenable/nessus/proxy.py index 076012e00..4d6f2fa74 100644 --- a/tenable/nessus/proxy.py +++ b/tenable/nessus/proxy.py @@ -9,9 +9,9 @@ .. autoclass:: ProxyAPI :members: ''' -from typing import List, Dict, Optional +from typing import Dict, Optional from typing_extensions import Literal -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/scanners.py b/tenable/nessus/scanners.py index d327fa17f..3137b1c13 100644 --- a/tenable/nessus/scanners.py +++ b/tenable/nessus/scanners.py @@ -11,7 +11,7 @@ ''' from typing import List, Dict, Optional from typing_extensions import Literal -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/scans.py b/tenable/nessus/scans.py index 85d41fc08..a2b6ba15d 100644 --- a/tenable/nessus/scans.py +++ b/tenable/nessus/scans.py @@ -11,7 +11,7 @@ ''' from typing import Dict, List, Optional from io import BytesIO -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint from .schema.scans import ScanExportSchema diff --git a/tenable/nessus/schema/pagination.py b/tenable/nessus/schema/pagination.py index f0cb4f57f..c1245cb9e 100644 --- a/tenable/nessus/schema/pagination.py +++ b/tenable/nessus/schema/pagination.py @@ -6,9 +6,9 @@ from marshmallow import Schema, fields, post_dump, pre_load from marshmallow import validate as v -from restfly.utils import dict_clean from tenable.base.schema.fields import LowerCase +from tenable.utils import dict_clean class FilterSchema(Schema): diff --git a/tenable/nessus/server.py b/tenable/nessus/server.py index b128e0cb6..cbde74aad 100644 --- a/tenable/nessus/server.py +++ b/tenable/nessus/server.py @@ -9,9 +9,8 @@ .. autoclass:: ServerAPI :members: ''' -from typing import List, Dict, Optional -from typing_extensions import Literal -from restfly.utils import dict_clean +from typing import Dict, Optional +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/session.py b/tenable/nessus/session.py index 49e83b912..3799b3751 100644 --- a/tenable/nessus/session.py +++ b/tenable/nessus/session.py @@ -10,7 +10,7 @@ :members: ''' from typing import Dict, Optional -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/settings.py b/tenable/nessus/settings.py index 987b01397..7d19a89b1 100644 --- a/tenable/nessus/settings.py +++ b/tenable/nessus/settings.py @@ -10,8 +10,7 @@ :members: ''' from typing import List, Dict, Optional -from typing_extensions import Literal -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint from .schema.settings import SettingsListSchema diff --git a/tenable/nessus/software_update.py b/tenable/nessus/software_update.py index 6dedc1e7e..69f2a095b 100644 --- a/tenable/nessus/software_update.py +++ b/tenable/nessus/software_update.py @@ -9,9 +9,9 @@ .. autoclass:: SoftwareUpdateAPI :members: ''' -from typing import Dict, Optional +from typing import Optional from typing_extensions import Literal -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/tokens.py b/tenable/nessus/tokens.py index c6a3e3060..4ed94290b 100644 --- a/tenable/nessus/tokens.py +++ b/tenable/nessus/tokens.py @@ -11,10 +11,8 @@ ''' import time from io import BytesIO -from typing import List, Dict, Optional, Callable -from typing_extensions import Literal +from typing import Dict, Optional, Callable from requests import Response -from restfly.utils import dict_clean from tenable.errors import FileDownloadError from tenable.base.endpoint import APIEndpoint diff --git a/tenable/nessus/users.py b/tenable/nessus/users.py index 27ceff780..b8639bbee 100644 --- a/tenable/nessus/users.py +++ b/tenable/nessus/users.py @@ -11,7 +11,7 @@ ''' from typing import List, Dict, Optional from typing_extensions import Literal -from restfly.utils import dict_clean +from tenable.utils import dict_clean from tenable.base.endpoint import APIEndpoint diff --git a/tenable/ot/exports/iterator.py b/tenable/ot/exports/iterator.py index f2df1d7b1..0da6d3240 100644 --- a/tenable/ot/exports/iterator.py +++ b/tenable/ot/exports/iterator.py @@ -1,11 +1,13 @@ from typing import Dict -from restfly.iterator import APIIterator + +from tenable.base._restfly_v1 import APIIterator class OTExportsIterator(APIIterator): """ Tenable OT Security Exports Iterator """ + _model: str _query: str _variables: Dict @@ -14,13 +16,13 @@ def _get_page(self): """ Fetches the next page of data from the GraphQL API """ - resp = self._api.graphql(query=self._query, - variables=self._variables, - ) + resp = self._api.graphql( + query=self._query, + variables=self._variables, + ) raw_page = resp.get('data', {}).get(self._model, {}) self.page = raw_page.get('nodes', []) - self._variables['startAt'] = raw_page.get('pageInfo', {})\ - .get('endCursor', None) + self._variables['startAt'] = raw_page.get('pageInfo', {}).get('endCursor', None) self.total = raw_page.get('count') return self.page @@ -29,6 +31,7 @@ class OTFindingsIterator(APIIterator): """ Tenable OT Security Findings Iterator """ + empty_asset_count: int = 0 _assets: OTExportsIterator @@ -44,7 +47,7 @@ def _get_page(self): try: asset = self._assets.next() except StopIteration: - raise StopIteration() + raise StopIteration() from None self._asset_id = asset['id'] items = self._api.get(f'v1/assets/{self._asset_id}/plugin_hits') self.page = items diff --git a/tenable/ot/graphql/iterators.py b/tenable/ot/graphql/iterators.py index 767f4f682..e3920bd73 100644 --- a/tenable/ot/graphql/iterators.py +++ b/tenable/ot/graphql/iterators.py @@ -6,7 +6,8 @@ import typing from typing import Type -from restfly.iterator import APIIterator + +from tenable.base._restfly_v1 import APIIterator from tenable.ot.graphql.definitions import ( GraphObject, diff --git a/tenable/ot/schema/assets.py b/tenable/ot/schema/assets.py index 20262186c..c63cf6129 100644 --- a/tenable/ot/schema/assets.py +++ b/tenable/ot/schema/assets.py @@ -1,5 +1,4 @@ import datetime -import ipaddress import typing import uuid from typing import List, Optional @@ -7,7 +6,7 @@ from dataclasses import dataclass from tenable.ot.schema.base import NodesList, IPList, ID -from tenable.ot.schema.plugins import Plugin, Plugins +from tenable.ot.schema.plugins import Plugins @dataclass diff --git a/tenable/ot/schema/base.py b/tenable/ot/schema/base.py index 7e2660022..d0d0f6608 100644 --- a/tenable/ot/schema/base.py +++ b/tenable/ot/schema/base.py @@ -24,12 +24,6 @@ def __getitem__(self, index): return self.nodes[index] -@dataclass -class AssetInfo: - id: uuid.UUID - name: str - - @dataclass class AssetInfoList(NodesList): nodes: List[AssetInfo] diff --git a/tenable/ot/schema/events.py b/tenable/ot/schema/events.py index 9e99e4937..e087536d4 100644 --- a/tenable/ot/schema/events.py +++ b/tenable/ot/schema/events.py @@ -1,22 +1,11 @@ import datetime import ipaddress -import typing import uuid from dataclasses import dataclass from typing import List, Optional from tenable.ot.schema.assets import NetworkInterface -from tenable.ot.schema.base import ( - NodesList, - AssetInfoList, - IDList -) - - -@dataclass -class Action: - aid: uuid.UUID - type: str +from tenable.ot.schema.base import AssetInfoList, IDList, NodesList @dataclass @@ -46,12 +35,6 @@ class GroupMember: negate: bool -@dataclass -class GroupMember: - group: Group - negate: bool - - @dataclass class EventTypeDetails: type: str diff --git a/tenable/ot/session.py b/tenable/ot/session.py index 7f8c72048..e39f5c4e2 100644 --- a/tenable/ot/session.py +++ b/tenable/ot/session.py @@ -17,7 +17,6 @@ plugins """ import os -import warnings from tenable.base.platform import APIPlatform from tenable.ot.assets import AssetsAPI diff --git a/tenable/reports/nessusv2.py b/tenable/reports/nessusv2.py index 03d4feff2..cdb6af7b1 100644 --- a/tenable/reports/nessusv2.py +++ b/tenable/reports/nessusv2.py @@ -1,19 +1,21 @@ -''' +""" .. autoclass:: NessusReportv2 -''' +""" + from tenable.errors import PackageMissingError try: from defusedxml.ElementTree import iterparse -except ImportError: +except ImportError as err: raise PackageMissingError( - 'The python package defusedxml is required for NessusReportv2') + 'The python package defusedxml is required for NessusReportv2' + ) from err -import dateutil.parser, time +import dateutil.parser class NessusReportv2(object): - ''' + """ The NessusReportv2 generator will return vulnerability items from any Nessus version 2 formatted Nessus report file. The returned data will be a python dictionary representation of the ReportItem with the relevant @@ -36,7 +38,8 @@ class NessusReportv2(object): ... report = NessusReportv2(nessus_file) ... for item in report: ... print(item) - ''' + """ + def __init__(self, fobj): self._iter = iterparse(fobj, events=('start', 'end')) @@ -57,8 +60,14 @@ def _defs(self, name, value): if value: return float(value) - elif name in ['first_found', 'last_found', 'plugin_modification_date', - 'plugin_publication_date', 'HOST_END', 'HOST_START']: + elif name in [ + 'first_found', + 'last_found', + 'plugin_modification_date', + 'plugin_publication_date', + 'HOST_END', + 'HOST_START', + ]: # The first and last found attributes use a datetime timestamp # format that we should convert into a unix timestamp. return dateutil.parser.parse(value) @@ -70,13 +79,13 @@ def _defs(self, name, value): return value def next(self): - ''' + """ Get the next ReportItem from the nessus file and return it as a python dictionary. Generally speaking this method is not called directly, but is instead called as part of a loop. - ''' + """ try: for event, elem in self._iter: if event == 'start' and elem.tag == 'ReportHost': @@ -126,7 +135,9 @@ def next(self): if c.tag in vuln: if not isinstance(vuln[c.tag], list): - vuln[c.tag] = [vuln[c.tag],] + vuln[c.tag] = [ + vuln[c.tag], + ] vuln[c.tag].append(self._defs(c.tag, c.text)) else: vuln[c.tag] = self._defs(c.tag, c.text) @@ -137,6 +148,6 @@ def next(self): return vuln except TypeError as err: if err.args[0] == 'reading file objects must return bytes objects': - raise TypeError('File object not opened in binary mode.') + raise TypeError('File object not opened in binary mode.') from err else: raise err diff --git a/tenable/sc/alerts.py b/tenable/sc/alerts.py index 841ab22c9..a03873a0b 100644 --- a/tenable/sc/alerts.py +++ b/tenable/sc/alerts.py @@ -17,7 +17,6 @@ https://tools.ietf.org/html/rfc5545#section-3.3.10 ''' from .base import SCEndpoint -from tenable.utils import dict_merge class AlertAPI(SCEndpoint): def _constructor(self, *filters, **kw): diff --git a/tenable/sc/analysis.py b/tenable/sc/analysis.py index ff4931a0f..209a83721 100644 --- a/tenable/sc/analysis.py +++ b/tenable/sc/analysis.py @@ -76,7 +76,6 @@ :members: ''' from .base import SCEndpoint, SCResultsIterator -from tenable.utils import dict_merge from tenable.errors import UnexpectedValueError class AnalysisResultsIterator(SCResultsIterator): diff --git a/tenable/sc/base.py b/tenable/sc/base.py index 72d36068e..f9d1a5879 100644 --- a/tenable/sc/base.py +++ b/tenable/sc/base.py @@ -208,7 +208,7 @@ def _query_constructor(self, *filters, **kw): # then skip appending. This should allow for effectively # removing an unwanted filter from a query if a query id is # specified. - if f[1] != None and f[2] != None: + if f[1] is not None and f[2] is not None: kw['query']['filters'].append(item) del kw['type'] return kw diff --git a/tenable/sc/repositories.py b/tenable/sc/repositories.py index 688fae62f..b7b023418 100644 --- a/tenable/sc/repositories.py +++ b/tenable/sc/repositories.py @@ -83,8 +83,8 @@ def _constructor(self, **kwargs): del kwargs['trending'] if 'fulltext_search' in kwargs: - # trendWithRaw is the backend paramater name for "Full Text Search" - # within the UI. We will be calling it fulltest_search to more + # trendWithRaw is the backend parameter name for "Full Text Search" + # within the UI. We will be calling it fulltext_search to more # closely align with what the frontend calls this feature. kwargs['trendWithRaw'] = str( self._check('fulltext_search', kwargs['fulltext_search'], bool) @@ -95,19 +95,21 @@ def _constructor(self, **kwargs): # The correlation parameter isn't well named here, we will call it # out as LCE correlation to specifically note what it is for. kwargs['correlation'] = [ - {'id': self._check('lce_id', l, int)} - for l in self._check('lce_correlation', kwargs['lce_correlation'], list) + {'id': self._check('lce_id', lce, int)} + for lce in self._check( + 'lce_correlation', kwargs['lce_correlation'], list + ) ] del kwargs['lce_correlation'] if 'allowed_ips' in kwargs: # Using valid IPs here instead of ipRange to again more closely # align to the frontend and to more explicitly call out the - # function of this paramater + # function of this parameter kwargs['ipRange'] = ','.join( [ - self._check('ip', i, str) - for i in self._check('allowed_ips', kwargs['allowed_ips'], list) + self._check('ip', ip, str) + for ip in self._check('allowed_ips', kwargs['allowed_ips'], list) ] ) del kwargs['allowed_ips'] diff --git a/tenable/sc/scan_instances.py b/tenable/sc/scan_instances.py index 5479eba67..edf49b3a8 100644 --- a/tenable/sc/scan_instances.py +++ b/tenable/sc/scan_instances.py @@ -19,7 +19,6 @@ from io import BytesIO -from tenable.utils import dict_merge from .base import SCEndpoint diff --git a/tenable/sc/scans.py b/tenable/sc/scans.py index 882acbc88..d0863ada3 100644 --- a/tenable/sc/scans.py +++ b/tenable/sc/scans.py @@ -16,8 +16,6 @@ :members: """ -from tenable.errors import UnexpectedValueError -from tenable.utils import dict_merge from .base import SCEndpoint diff --git a/tenable/sc/users.py b/tenable/sc/users.py index 280dbbcbf..ade146b3b 100644 --- a/tenable/sc/users.py +++ b/tenable/sc/users.py @@ -13,7 +13,7 @@ :members: ''' from typing import Dict, List, Optional -from restfly.utils import dict_clean +from tenable.utils import dict_clean from .base import SCEndpoint class UserAPI(SCEndpoint): diff --git a/tenable/tenableone/attack_path/findings/api.py b/tenable/tenableone/attack_path/findings/api.py index a234f8be0..0b90953b6 100644 --- a/tenable/tenableone/attack_path/findings/api.py +++ b/tenable/tenableone/attack_path/findings/api.py @@ -14,7 +14,7 @@ from copy import copy from typing import Dict, Optional, Union -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.base.endpoint import APIEndpoint from tenable.tenableone.attack_path.findings.schema import FindingsPageSchema diff --git a/tenable/tenableone/attack_path/findings/schema.py b/tenable/tenableone/attack_path/findings/schema.py index f8d8b0662..de0c0993e 100644 --- a/tenable/tenableone/attack_path/findings/schema.py +++ b/tenable/tenableone/attack_path/findings/schema.py @@ -1,6 +1,6 @@ from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel from typing import List, Optional diff --git a/tenable/tenableone/attack_path/vectors/api.py b/tenable/tenableone/attack_path/vectors/api.py index ca097c3df..3d86f15fa 100644 --- a/tenable/tenableone/attack_path/vectors/api.py +++ b/tenable/tenableone/attack_path/vectors/api.py @@ -13,7 +13,7 @@ from copy import copy from typing import Dict, Optional, Union -from restfly import APIIterator +from tenable.base._restfly_v1 import APIIterator from tenable.base.endpoint import APIEndpoint from tenable.tenableone.attack_path.vectors.schema import ( diff --git a/tenable/tenableone/exposure_view/api.py b/tenable/tenableone/exposure_view/api.py index b2f78b7dc..5905e3a93 100644 --- a/tenable/tenableone/exposure_view/api.py +++ b/tenable/tenableone/exposure_view/api.py @@ -16,7 +16,7 @@ cards/index """ -from restfly import APIEndpoint +from tenable.base._restfly_v1 import APIEndpoint from tenable.tenableone.exposure_view.cards.api import CardsAPI diff --git a/tenable/tenableone/exposure_view/cards/api.py b/tenable/tenableone/exposure_view/cards/api.py index 5c4925aba..69998febf 100644 --- a/tenable/tenableone/exposure_view/cards/api.py +++ b/tenable/tenableone/exposure_view/cards/api.py @@ -11,7 +11,7 @@ from typing import Optional -from restfly import APIEndpoint +from tenable.base._restfly_v1 import APIEndpoint from tenable.tenableone.exposure_view.cards.schema import ( Cards, diff --git a/tenable/tenableone/inventory/export/api.py b/tenable/tenableone/inventory/export/api.py index ad4fed4ff..b717e7e25 100644 --- a/tenable/tenableone/inventory/export/api.py +++ b/tenable/tenableone/inventory/export/api.py @@ -23,7 +23,7 @@ ExportType, ExportJobsResponse ) -from tenable.tenableone.inventory.schema import PropertyFilter, QueryMode, Query +from tenable.tenableone.inventory.schema import PropertyFilter, Query from tenable.tenableone.inventory.schema import SortDirection diff --git a/tenable/utils.py b/tenable/utils.py index 7ea8d3839..6d2000e14 100644 --- a/tenable/utils.py +++ b/tenable/utils.py @@ -1,7 +1,30 @@ +import string import warnings +from typing import Any -from restfly.utils import dict_merge as _dm -from restfly.utils import url_validator +from tenable.base._restfly_v1 import ( + check, + dict_clean, + dict_flatten, + force_case, + redact_values, + trunc, + url_validator, +) +from tenable.base._restfly_v1 import dict_merge as _dm + +__all__ = [ + 'check', + 'dict_clean', + 'dict_flatten', + 'dict_merge', + 'force_case', + 'redact_values', + 'url_validator', + 'policy_settings', + 'scrub', + 'trunc', +] def dict_merge(m, *args, **kwargs): @@ -34,7 +57,7 @@ def policy_settings(item): # if we find both an 'id' and a 'default' attribute, or if we find # a 'type' attribute matching one of the known attribute types, then # we will parse out the data and append it to the response dictionary - if not 'default' in item: + if 'default' not in item: item['default'] = '' resp[item['id']] = item['default'] @@ -56,3 +79,11 @@ def policy_settings(item): # Return the key-value pair. return resp + + +def scrub(value: Any) -> str: + """ + Scrubs converts the value to a string and then scrubs out any illegal characters. + """ + safe_chars = string.ascii_letters + string.digits + '-_%@' + return ''.join([c for c in str(value) if c in safe_chars]) diff --git a/tenable/version.py b/tenable/version.py index 8bb63cfcd..86124268f 100644 --- a/tenable/version.py +++ b/tenable/version.py @@ -1,2 +1,2 @@ -version = '1.9.1' +version = '26.05.01' version_info = tuple(int(d) for d in version.split('-')[0].split('.')) diff --git a/tests/asm/test_inventory.py b/tests/asm/test_inventory.py index e813b3d29..dba4398c2 100644 --- a/tests/asm/test_inventory.py +++ b/tests/asm/test_inventory.py @@ -1,4 +1,3 @@ -import pytest import responses from responses.registries import OrderedRegistry from responses.matchers import json_params_matcher, query_param_matcher diff --git a/tests/asm/test_smart_folders.py b/tests/asm/test_smart_folders.py index cd06f6899..7dd406c32 100644 --- a/tests/asm/test_smart_folders.py +++ b/tests/asm/test_smart_folders.py @@ -1,4 +1,3 @@ -import pytest import responses from tenable.asm import TenableASM diff --git a/tests/base/test_endpoint.py b/tests/base/test_endpoint.py index 8695af748..cc82ff39f 100644 --- a/tests/base/test_endpoint.py +++ b/tests/base/test_endpoint.py @@ -1,5 +1,4 @@ from tenable.base.endpoint import APIEndpoint -import pytest def test_apiendpoint(): diff --git a/tests/base/test_graphql.py b/tests/base/test_graphql.py index 8c5101319..bf36a8e6d 100644 --- a/tests/base/test_graphql.py +++ b/tests/base/test_graphql.py @@ -2,7 +2,6 @@ Base graphql Testing module. """ -import os import platform import sys from io import StringIO @@ -11,7 +10,6 @@ import pytest import responses -from gql import GraphQLRequest from graphql import parse from responses.registries import OrderedRegistry diff --git a/tests/checker.py b/tests/checker.py index 4f0a7fd12..386469ef2 100644 --- a/tests/checker.py +++ b/tests/checker.py @@ -1,5 +1,7 @@ from dateutil.parser import parse as dateparse -import datetime, sys, re +import datetime +import sys +import re def check(i, name, val_type, allow_none=False): diff --git a/tests/io/conftest.py b/tests/io/conftest.py index 9a6c5df58..5bcb885bc 100644 --- a/tests/io/conftest.py +++ b/tests/io/conftest.py @@ -1,11 +1,14 @@ -'''conftest''' +"""conftest""" + import os import uuid + import pytest import responses -from tenable.errors import NotFoundError + +from tenable.errors import APIError, NotFoundError from tenable.io import TenableIO -from tests.pytenable_log_handler import setup_logging_to_file, log_exception +from tests.pytenable_log_handler import log_exception, setup_logging_to_file SCAN_ID_WITH_RESULTS = 6799 @@ -13,15 +16,14 @@ @pytest.fixture @responses.activate def tvm(): - return TenableIO(url='https://nourl', - access_key='ACCESS_KEY', - secret_key='SECRET_KEY' - ) + return TenableIO( + url='https://nourl', access_key='ACCESS_KEY', secret_key='SECRET_KEY' + ) @pytest.fixture(scope='module') def vcr_config(): - '''vcr config fixture''' + """vcr config fixture""" return { 'filter_headers': [ ('X-APIKeys', 'accessKey=TIO_ACCESS_KEY;secretKey=TIO_SECRET_KEY'), @@ -32,38 +34,40 @@ def vcr_config(): @pytest.fixture def api(): - '''api keys fixture''' + """api keys fixture""" setup_logging_to_file() return TenableIO( os.getenv('TIO_TEST_ADMIN_ACCESS', 'ffffffffffffffffffffffffffffffff'), os.getenv('TIO_TEST_ADMIN_SECRET', 'ffffffffffffffffffffffffffffffff'), vendor='pytest', - product='pytenable-automated-testing') + product='pytenable-automated-testing', + ) @pytest.fixture def stdapi(): - '''std api keys fixture''' + """std api keys fixture""" return TenableIO( os.getenv('TIO_TEST_STD_ACCESS', 'ffffffffffffffffffffffffffffffff'), os.getenv('TIO_TEST_STD_SECRET', 'ffffffffffffffffffffffffffffffff'), vendor='pytest', - product='pytenable-automated-testing') + product='pytenable-automated-testing', + ) @pytest.fixture def agent(api): - '''agent fixture''' + """agent fixture""" return api.agents.list().next() @pytest.fixture def folder(request, api): - '''fixture to create a folder''' + """fixture to create a folder""" folder = api.folders.create(str(uuid.uuid4())[:20]) def teardown(): - '''function to clear the folder''' + """function to clear the folder""" try: api.folders.delete(folder) except NotFoundError as notfound: @@ -75,17 +79,19 @@ def teardown(): @pytest.fixture def policy(request, api): - '''fixture to create a policy''' - policy = api.policies.create({ - 'credentials': {'add': {}, 'delete': [], 'edit': {}}, - 'settings': { - 'name': str(uuid.uuid4()), - }, - 'uuid': '731a8e52-3ea6-a291-ec0a-d2ff0619c19d7bd788d6be818b65' - }) + """fixture to create a policy""" + policy = api.policies.create( + { + 'credentials': {'add': {}, 'delete': [], 'edit': {}}, + 'settings': { + 'name': str(uuid.uuid4()), + }, + 'uuid': '731a8e52-3ea6-a291-ec0a-d2ff0619c19d7bd788d6be818b65', + } + ) def teardown(): - '''function to clear policy''' + """function to clear policy""" try: api.policies.delete(policy['policy_id']) except NotFoundError as notfound: @@ -97,14 +103,13 @@ def teardown(): @pytest.fixture def user(request, api): - '''fixture to create an user''' + """fixture to create an user""" user = api.users.create( - '{}@tenable.com'.format(uuid.uuid4()), - '{}Tt!'.format(uuid.uuid4()), - 64) + '{}@tenable.com'.format(uuid.uuid4()), '{}Tt!'.format(uuid.uuid4()), 64 + ) def teardown(): - '''function to clear the user''' + """function to clear the user""" try: api.users.delete(user['id']) except NotFoundError as notfound: @@ -116,7 +121,7 @@ def teardown(): @pytest.fixture def scanner(api): - '''fixture to filter scanner which has owner permission''' + """fixture to filter scanner which has owner permission""" scanners = api.scanners.list() for scanner in scanners: if scanner['user_permissions'] == 128 and not scanner['pool']: @@ -125,13 +130,13 @@ def scanner(api): @pytest.fixture def scannergroup(request, api): - ''' + """ fixture to create a scanner_group - ''' + """ scannergroup = api.scanner_groups.create(str(uuid.uuid4())) def teardown(): - '''function to clear the scanner_group''' + """function to clear the scanner_group""" try: api.scanner_groups.delete(scannergroup['id']) except NotFoundError as notfound: @@ -143,18 +148,17 @@ def teardown(): @pytest.fixture def scan(request, api): - ''' + """ fixture to create a scan - ''' + """ scan = api.scans.create( - name='pytest: {}'.format(uuid.uuid4()), - template='basic', - targets=['127.0.0.1']) + name='pytest: {}'.format(uuid.uuid4()), template='basic', targets=['127.0.0.1'] + ) def teardown(): - ''' + """ function to clear the scan - ''' + """ try: api.scans.delete(scan['id']) except NotFoundError as notfound: @@ -166,21 +170,22 @@ def teardown(): @pytest.fixture def remediationscan(request, api): - ''' + """ remediation scan fixture - ''' + """ scan = api.remediationscans.create_remediation_scan( uuid='76d67790-2969-411e-a9d0-667f05e8d49e', name='RemedyScan', description='RemediationScan Creation', scan_time_window=10, targets=['http://127.0.0.1'], - template='advanced') + template='advanced', + ) def teardown(): - ''' + """ function to delete the scan - ''' + """ try: api.scans.delete(scan['id']) except NotFoundError as notfound: @@ -192,11 +197,16 @@ def teardown(): @pytest.fixture def scan_results(api): - '''fixture to get the scan results''' - scan_list = [id['id'] for id in list(filter(lambda value: value['status'] == 'completed', api.scans.list()))] + """fixture to get the scan results""" + scan_list = [ + id['id'] + for id in list( + filter(lambda value: value['status'] == 'completed', api.scans.list()) + ) + ] if scan_list: return {'results': api.scans.results(scan_list[0]), 'id': scan_list[0]} - raise NotFoundError("Scan not found") + raise NotFoundError('Scan not found') @pytest.fixture @@ -214,3 +224,26 @@ def teardown(): request.addfinalizer(teardown) return targetFile + + +@pytest.fixture(name='network') +def fixture_network(request, api, vcr): + """ + Fixture to create network + """ + with vcr.use_cassette('test_networks_create_success'): + network = api.networks.create('Network-{}'.format(uuid.uuid4())) + + def teardown(): + """ + cleanup function to delete network + """ + try: + with vcr.use_cassette('test_networks_delete_success'): + api.networks.delete(network['uuid']) + except APIError as err: + log_exception(err) + pass + + request.addfinalizer(teardown) + return network diff --git a/tests/io/cs/test_images.py b/tests/io/cs/test_images.py index a91d96bf7..52fc32ec0 100644 --- a/tests/io/cs/test_images.py +++ b/tests/io/cs/test_images.py @@ -2,7 +2,6 @@ Test the CS Images API ''' import re -import pytest import responses from tenable.io.cs.iterator import CSIterator diff --git a/tests/io/cs/test_iterator.py b/tests/io/cs/test_iterator.py index ac33ff0e8..0a16af4f0 100644 --- a/tests/io/cs/test_iterator.py +++ b/tests/io/cs/test_iterator.py @@ -1,8 +1,6 @@ ''' Testing the CS iterator ''' -import re -import pytest import responses from tenable.io.cs.iterator import CSIterator diff --git a/tests/io/cs/test_reports.py b/tests/io/cs/test_reports.py index 632400313..e4fd2a3d9 100644 --- a/tests/io/cs/test_reports.py +++ b/tests/io/cs/test_reports.py @@ -2,7 +2,6 @@ Test the CS Reports API ''' import re -import pytest import responses diff --git a/tests/io/cs/test_repositories.py b/tests/io/cs/test_repositories.py index 7fc530a19..99f736be8 100644 --- a/tests/io/cs/test_repositories.py +++ b/tests/io/cs/test_repositories.py @@ -2,7 +2,6 @@ Test the CS Images API ''' import re -import pytest import responses from tenable.io.cs.iterator import CSIterator diff --git a/tests/io/exports/test_api.py b/tests/io/exports/test_api.py index 4899d4ea7..f1e599d7e 100644 --- a/tests/io/exports/test_api.py +++ b/tests/io/exports/test_api.py @@ -7,7 +7,7 @@ import pytest import responses -from restfly.errors import RequestConflictError +from tenable.errors import RequestConflictError from tenable.io.exports.iterator import ExportsIterator diff --git a/tests/io/exports/test_models.py b/tests/io/exports/test_models.py index 023fa0b31..07d7f9040 100644 --- a/tests/io/exports/test_models.py +++ b/tests/io/exports/test_models.py @@ -1,4 +1,4 @@ -from uuid import UUID, uuid4 +from uuid import UUID import pytest from pydantic import ValidationError diff --git a/tests/io/exports/test_was_api.py b/tests/io/exports/test_was_api.py index c1ca9bb3b..bb9552851 100644 --- a/tests/io/exports/test_was_api.py +++ b/tests/io/exports/test_was_api.py @@ -7,7 +7,7 @@ import pytest import responses -from restfly.errors import RequestConflictError +from tenable.errors import RequestConflictError from tenable.io.exports.iterator import ExportsIterator diff --git a/tests/io/sync/models/test_device_asset.py b/tests/io/sync/models/test_device_asset.py index d66b1d60c..8497e39ee 100644 --- a/tests/io/sync/models/test_device_asset.py +++ b/tests/io/sync/models/test_device_asset.py @@ -1,4 +1,3 @@ -import pytest from tenable.io.sync.models import device_asset as d diff --git a/tests/io/sync/test_job_manager.py b/tests/io/sync/test_job_manager.py index 1b3b5bed7..e78cd8ff0 100644 --- a/tests/io/sync/test_job_manager.py +++ b/tests/io/sync/test_job_manager.py @@ -4,10 +4,7 @@ import pytest import responses from pydantic import ValidationError -from requests import status_codes -from responses.matchers import json_params_matcher, query_param_matcher from responses.registries import OrderedRegistry -from restfly.errors import InvalidContentError from tenable.io.sync.job_manager import JobManager, SyncJobTerminated diff --git a/tests/io/test_agent_config.py b/tests/io/test_agent_config.py index deac2ff3d..35b7bb5c4 100644 --- a/tests/io/test_agent_config.py +++ b/tests/io/test_agent_config.py @@ -1,61 +1,54 @@ -''' +""" test agent_config -''' -import pytest -from tests.checker import check -from tenable.errors import UnexpectedValueError, ForbiddenError +""" +import pytest -@pytest.mark.vcr() -def test_agentconfig_edit_scanner_id_typeerror(api): - ''' - test to raise exception when type of scanner_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.agent_config.edit(scanner_id='nope') +from tenable.errors import ForbiddenError, UnexpectedValueError +from tests.checker import check @pytest.mark.vcr() def test_agentconfig_edit_software_update_typeerror(api): - ''' + """ test to raise exception when type of software_update param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.agent_config.edit(software_update='nope') @pytest.mark.vcr() def test_agentconfig_edit_auto_unlink_typerror(api): - ''' + """ test to raise exception when type of auto_unlink param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.agent_config.edit(auto_unlink='nope') @pytest.mark.vcr() def test_agentconfig_edit_auto_unlink_out_of_bounds(api): - ''' + """ test to raise exception when auto_unlink param value does not match the choices. - ''' + """ with pytest.raises(UnexpectedValueError): api.agent_config.edit(auto_unlink=500) @pytest.mark.vcr() def test_agentconfig_edit_standard_user_should_fail(stdapi): - ''' + """ test to raise exception when standard_user tries to edit agent_config. - ''' + """ with pytest.raises(ForbiddenError): stdapi.agent_config.edit(auto_unlink=30) @pytest.mark.vcr() def test_agentconfig_edit_set_autounlink(api): - ''' + """ test to edit autounlink param. - ''' + """ resp = api.agent_config.edit(auto_unlink=31) assert isinstance(resp, dict) check(resp, 'auto_unlink', dict) @@ -67,9 +60,9 @@ def test_agentconfig_edit_set_autounlink(api): @pytest.mark.vcr() def test_agentconfig_edit_disable_autounlink(api): - ''' + """ test to disable autounlink param. - ''' + """ resp = api.agent_config.edit(auto_unlink=False) assert isinstance(resp, dict) check(resp, 'auto_unlink', dict) @@ -81,9 +74,9 @@ def test_agentconfig_edit_disable_autounlink(api): @pytest.mark.vcr() def test_agentconfig_edit_disable_softwareupdate(api): - ''' + """ test to disable software_update param. - ''' + """ resp = api.agent_config.edit(software_update=False) assert isinstance(resp, dict) check(resp, 'auto_unlink', dict) @@ -93,20 +86,11 @@ def test_agentconfig_edit_disable_softwareupdate(api): assert resp['software_update'] is False -@pytest.mark.vcr() -def test_agentconfig_show_error_conditions(api): - ''' - test to show error conditions - ''' - with pytest.raises(TypeError): - api.agent_config.details(scanner_id='nope') - - @pytest.mark.vcr() def test_agentconfig_show_details(api): - ''' + """ test to show agent_config details - ''' + """ resp = api.agent_config.details() assert isinstance(resp, dict) check(resp, 'auto_unlink', dict) @@ -117,18 +101,18 @@ def test_agentconfig_show_details(api): @pytest.mark.vcr() def test_agentconfig_show_standard_user_should_fail(stdapi): - ''' + """ test to raise exception when standard user try to view details of agent_config - ''' + """ with pytest.raises(ForbiddenError): stdapi.agent_config.details() @pytest.mark.vcr() def test_agentconfig_show_details_fields(api): - ''' + """ test to show agent_config details - ''' + """ resp = api.agent_config.details(scanner_id=0) assert isinstance(resp, dict) check(resp, 'auto_unlink', dict) diff --git a/tests/io/test_agents.py b/tests/io/test_agents.py index b6323d5b6..1c490101b 100644 --- a/tests/io/test_agents.py +++ b/tests/io/test_agents.py @@ -1,125 +1,118 @@ -''' +""" test agents -''' +""" + import pytest from tenable.errors import UnexpectedValueError -from ..checker import check - -@pytest.mark.vcr() -def test_agents_list_scanner_id_typeerror(api): - ''' - test to raise the exception when type of scanner_id is not as defined - ''' - with pytest.raises(TypeError): - api.agents.list(scanner_id='nope') +from ..checker import check @pytest.mark.vcr() def test_agents_list_offset_typeerror(api): - ''' + """ test to raise the exception when type of offset is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(offset='nope') @pytest.mark.vcr() def test_agents_list_limit_typeerror(api): - ''' + """ test to raise the exception when type of limit is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(limit='nope') @pytest.mark.vcr() def test_agents_list_sort_field_typeerror(api): - ''' + """ test to raise the exception when type of sort field is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(sort=((1, 'asc'),)) @pytest.mark.vcr() def test_agents_list_sort_direction_typeerror(api): - ''' + """ test to raise the exception when type of sort direction is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(sort=(('uuid', 1),)) @pytest.mark.vcr() def test_agents_list_sort_direction_unexpectedvalue(api): - ''' + """ test to raise the exception when value of sort direction is not as defined - ''' + """ with pytest.raises(UnexpectedValueError): api.agents.list(sort=(('uuid', 'nope'),)) @pytest.mark.vcr() def test_agents_list_filter_name_typeerror(api): - ''' + """ test to raise the exception when type of filter name is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list((1, 'match', 'win')) @pytest.mark.vcr() def test_agents_list_filter_operator_typeerror(api): - ''' + """ test to raise the exception when type of filter operator is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(('distro', 1, 'win')) @pytest.mark.vcr() def test_agents_list_filter_value_typeerror(api): - ''' + """ test to raise the exception when type of filter value is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(('distro', 'match', 1)) @pytest.mark.vcr() def test_agents_list_filter_type_typeerror(api): - ''' + """ test to raise the exception when type of filter type is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(filter_type=1) @pytest.mark.vcr() def test_agents_list_wildcard_typeerror(api): - ''' + """ test to raise the exception when type of wildcard is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(wildcard=1) @pytest.mark.vcr() def test_agents_list_wildcard_fields_typeerror(api): - ''' + """ test to raise the exception when type of wildcard fields is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.list(wildcard_fields='nope') @pytest.mark.vcr() def test_agents_list(api): - ''' + """ test to get the agents list - ''' + """ count = 0 agents = api.agents.list() for agent in agents: @@ -137,27 +130,18 @@ def test_agents_list(api): @pytest.mark.vcr() def test_agents_details_scanner_id_typeerror(api): - ''' + """ test to raise the exception when type of scanner_id is not as defined - ''' + """ with pytest.raises(TypeError): api.agents.details(scanner_id='nope') -@pytest.mark.vcr() -def test_agents_details_agent_id_typeerror(api): - ''' - test to raise the exception when type of agent_id is not as defined - ''' - with pytest.raises(TypeError): - api.agents.details('nope') - - @pytest.mark.vcr() def test_agents_details_agent_details(api, agent): - ''' + """ test to get the agent details - ''' + """ resp = api.agents.details(agent['id']) check(resp, 'distro', str) check(resp, 'id', int) @@ -174,16 +158,13 @@ def test_agents_details_agent_details(api, agent): # att tests for task_status. @pytest.mark.vcr() def test_agents_list_fields(api): - ''' + """ test to get the agent list - ''' + """ count = 0 agents = api.agents.list( - filter_type='or', - limit=45, - offset=5, - wildcard='match', - wildcard_fields=['name']) + filter_type='or', limit=45, offset=5, wildcard='match', wildcard_fields=['name'] + ) for agent in agents: count += 1 check(agent, 'distro', str) @@ -197,53 +178,36 @@ def test_agents_list_fields(api): assert count == agents.total -@pytest.mark.vcr() -def test_agents_unlink_agent_id_typeerror(api): - ''' - test to raise the exception when type of agent_id is not as defined - ''' - with pytest.raises(TypeError) as type_error: - api.agents.unlink('nope') - assert len(type_error.value.args) == 1, "Test-case should raise only one validation error." - - assert type_error.value.args[0] == "agent_id is of type str. Expected int", \ - "Invalid type validation error for agent_id parameter is not raised by test-case." - - @pytest.mark.vcr() def test_agents_unlink_multiple_agent_id_typeerror(api): - ''' + """ test to raise the exception when types of multiple agent_ids are not as defined - ''' + """ with pytest.raises(TypeError) as type_error: api.agents.unlink('nope', 'test', scanner_id=11) - assert len(type_error.value.args) == 1, "Test-case should raise only one validation error." + assert len(type_error.value.args) == 1, ( + 'Test-case should raise only one validation error.' + ) - assert type_error.value.args[0] == "agent_ids is of type str. Expected int", \ - "Invalid type validation error for agent_ids parameter is not raised by test-case." + assert type_error.value.args[0] == 'agent_ids is of type str. Expected int', ( + 'Invalid type validation error for agent_ids parameter is not raised by test-case.' + ) @pytest.mark.vcr() def test_agents_task_status_scanner_id_typeerror(api): - ''' + """ test to raise the exception when type of scanner_id is not as defined - ''' + """ with pytest.raises(TypeError) as type_error: api.agents.task_status(scanner_id='nope') - assert len(type_error.value.args) == 1, "Test-case should raise only one validation error." - - assert "task_status() missing 1 required positional argument: 'task_uuid'" in type_error.value.args[0], \ - "Missing value of required scanner_id parameter error is not raised by test-case." - - -@pytest.mark.vcr() -def test_agents_task_status_task_uuid_typeerror(api): - ''' - test to raise the exception when type of task_uuid is not as defined - ''' - with pytest.raises(TypeError) as type_error: - api.agents.task_status(task_uuid=11, scanner_id=11) - assert len(type_error.value.args) == 1, "Test-case should raise only one validation error." - - assert "task_uuid is of type int. Expected str" in type_error.value.args[0], \ - "Missing value of required task_uuid parameter error is not raised by test-case." + assert len(type_error.value.args) == 1, ( + 'Test-case should raise only one validation error.' + ) + + assert ( + "task_status() missing 1 required positional argument: 'task_uuid'" + in type_error.value.args[0] + ), ( + 'Missing value of required scanner_id parameter error is not raised by test-case.' + ) diff --git a/tests/io/test_assets.py b/tests/io/test_assets.py index 303a196ef..5ef1ca712 100644 --- a/tests/io/test_assets.py +++ b/tests/io/test_assets.py @@ -2,14 +2,12 @@ test assets """ -import time import uuid import pytest from tenable.errors import ForbiddenError, UnexpectedValueError from tests.checker import check, single -from tests.io.test_networks import fixture_network @pytest.mark.vcr() @@ -140,33 +138,6 @@ def test_assets_import_job_info(api): assert job['job_id'] == jobs[0]['job_id'] -@pytest.mark.vcr() -def test_assets_tags_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.assets.tags(1) - - -@pytest.mark.vcr() -def test_assets_tags_uuid_unexpectedvalueerror(api): - """ - test to raise exception when uuid param value does not match the choices. - """ - with pytest.raises(UnexpectedValueError): - api.assets.tags('somethign else') - - -@pytest.mark.vcr() -def test_workbenches_asset_delete_asset_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.asset_delete(1) - - @pytest.mark.vcr() @pytest.mark.skip("We don't want to actually delete an asset") def test_workbenches_asset_delete_success(api): diff --git a/tests/io/test_audit_log.py b/tests/io/test_audit_log.py index 95866b773..526297581 100644 --- a/tests/io/test_audit_log.py +++ b/tests/io/test_audit_log.py @@ -4,7 +4,6 @@ import pytest import responses from responses.matchers import query_param_matcher -from copy import copy @pytest.fixture diff --git a/tests/io/test_compile.py b/tests/io/test_compile.py index ca1cc6dc9..3a7c802a0 100644 --- a/tests/io/test_compile.py +++ b/tests/io/test_compile.py @@ -5,7 +5,7 @@ import pytest # from tests.io.conftest import api -from tenable.errors import AuthenticationWarning, UnexpectedValueError +from tenable.errors import UnexpectedValueError from tenable.io import TenableIO from tenable.io.agent_config import AgentConfigAPI from tenable.io.agent_exclusions import AgentExclusionsAPI diff --git a/tests/io/test_credentials.py b/tests/io/test_credentials.py index 532f816f6..ed220e3d0 100644 --- a/tests/io/test_credentials.py +++ b/tests/io/test_credentials.py @@ -1,148 +1,144 @@ -''' +""" test credentials -''' +""" + import io import uuid import pytest -from tenable.errors import UnexpectedValueError, APIError +from tenable.errors import APIError, UnexpectedValueError from tests.pytenable_log_handler import log_exception + from ..checker import check, single def test_credentials_permissions_constructor_tuple_permission_type_typeerror(api): - '''test to raise the exception when type of permission type is not passed as defined''' + """test to raise the exception when type of permission type is not passed as defined""" with pytest.raises(TypeError): - getattr(api.credentials, '_permissions_constructor')([ - (1, 32, str(uuid.uuid4()))]) + getattr(api.credentials, '_permissions_constructor')( + [(1, 32, str(uuid.uuid4()))] + ) -def test_credentials_permissions_constructor_tuple_permission_type_unexpectedvalueerror(api): - '''test to raise the exception when value of permission type is not passed as defined''' +def test_credentials_permissions_constructor_tuple_permission_type_unexpectedvalueerror( + api, +): + """test to raise the exception when value of permission type is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([ - ('nope', 32, str(uuid.uuid4()))]) + getattr(api.credentials, '_permissions_constructor')( + [('nope', 32, str(uuid.uuid4()))] + ) -def test_credentials_permissions_constructor_tuple_permission_permission_unexpectedvalueerror(api): - '''test to raise the exception when value of permission is not passed as defined''' +def test_credentials_permissions_constructor_tuple_permission_permission_unexpectedvalueerror( + api, +): + """test to raise the exception when value of permission is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([ - ('user', 256, str(uuid.uuid4()))]) + getattr(api.credentials, '_permissions_constructor')( + [('user', 256, str(uuid.uuid4()))] + ) def test_credentials_permissions_constructor_tuple_permission_uuid_typeerror(api): - '''test to raise the exception when type of permission is not passed as defined''' + """test to raise the exception when type of permission is not passed as defined""" with pytest.raises(TypeError): - getattr(api.credentials, '_permissions_constructor')([ - ('user', 32, 1)]) + getattr(api.credentials, '_permissions_constructor')([('user', 32, 1)]) -def test_credentials_permissions_constructor_tuple_permission_uuid_unexpectedvalueerror(api): - '''test to raise the exception when value of permission is not passed as defined''' +def test_credentials_permissions_constructor_tuple_permission_uuid_unexpectedvalueerror( + api, +): + """test to raise the exception when value of permission is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([ - ('user', 32, 'someone')]) + getattr(api.credentials, '_permissions_constructor')([('user', 32, 'someone')]) def test_credentials_permissions_constructor_dict_permission_type_typeerror(api): - '''test to raise the exception when type of permission type is not passed as defined''' + """test to raise the exception when type of permission type is not passed as defined""" with pytest.raises(TypeError): - getattr(api.credentials, '_permissions_constructor')([{ - 'type': 1, - 'permissions': 32, - 'grantee_uuid': str(uuid.uuid4()) - }]) + getattr(api.credentials, '_permissions_constructor')( + [{'type': 1, 'permissions': 32, 'grantee_uuid': str(uuid.uuid4())}] + ) -def test_credentials_permissions_constructor_dict_permission_type_unexpectedvalueerror(api): - '''test to raise the exception when value of permission type is not passed as defined''' +def test_credentials_permissions_constructor_dict_permission_type_unexpectedvalueerror( + api, +): + """test to raise the exception when value of permission type is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([{ - 'type': 'nope', - 'permissions': 32, - 'grantee_uuid': str(uuid.uuid4()) - }]) + getattr(api.credentials, '_permissions_constructor')( + [{'type': 'nope', 'permissions': 32, 'grantee_uuid': str(uuid.uuid4())}] + ) -def test_credentials_permissions_constructor_dict_permission_permission_unexpectedvalueerror(api): - '''test to raise the exception when value of permission is not passed as defined''' +def test_credentials_permissions_constructor_dict_permission_permission_unexpectedvalueerror( + api, +): + """test to raise the exception when value of permission is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([{ - 'type': 'user', - 'permissions': 256, - 'grantee_uuid': str(uuid.uuid4()) - }]) + getattr(api.credentials, '_permissions_constructor')( + [{'type': 'user', 'permissions': 256, 'grantee_uuid': str(uuid.uuid4())}] + ) def test_credentials_permissions_constructor_dict_permission_uuid_typeerror(api): - '''test to raise the exception when type of uuid is not passed as defined''' + """test to raise the exception when type of uuid is not passed as defined""" with pytest.raises(TypeError): - getattr(api.credentials, '_permissions_constructor')([{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': 1 - }]) + getattr(api.credentials, '_permissions_constructor')( + [{'type': 'user', 'permissions': 32, 'grantee_uuid': 1}] + ) -def test_credentials_permissions_constructor_dict_permission_uuid_unexpectedvalueerror(api): - '''test to raise the exception when value of uuid is not passed as defined''' +def test_credentials_permissions_constructor_dict_permission_uuid_unexpectedvalueerror( + api, +): + """test to raise the exception when value of uuid is not passed as defined""" with pytest.raises(UnexpectedValueError): - getattr(api.credentials, '_permissions_constructor')([{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': 'someone' - }]) + getattr(api.credentials, '_permissions_constructor')( + [{'type': 'user', 'permissions': 32, 'grantee_uuid': 'someone'}] + ) def test_credentials_permissions_constructor_tuple_success(api): - '''test to check the type of permission constructor as tuple''' + """test to check the type of permission constructor as tuple""" test_id = str(uuid.uuid4()) resp = getattr(api.credentials, '_permissions_constructor')([('user', 32, test_id)]) - assert resp == [{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': test_id - }] - resp = getattr(api.credentials, '_permissions_constructor')([('user', 'use', test_id)]) - assert resp == [{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': test_id - }] + assert resp == [{'type': 'user', 'permissions': 32, 'grantee_uuid': test_id}] + resp = getattr(api.credentials, '_permissions_constructor')( + [('user', 'use', test_id)] + ) + assert resp == [{'type': 'user', 'permissions': 32, 'grantee_uuid': test_id}] def test_credentials_permissions_constructor_dict_success(api): - '''test to check the type of permission constructor as dict''' + """test to check the type of permission constructor as dict""" test_id = str(uuid.uuid4()) - resp = getattr(api.credentials, '_permissions_constructor')([{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': test_id - }]) - assert resp == [{ - 'type': 'user', - 'permissions': 32, - 'grantee_uuid': test_id - }] + resp = getattr(api.credentials, '_permissions_constructor')( + [{'type': 'user', 'permissions': 32, 'grantee_uuid': test_id}] + ) + assert resp == [{'type': 'user', 'permissions': 32, 'grantee_uuid': test_id}] @pytest.fixture def cred(request, api, vcr): - '''fixture credential''' + """fixture credential""" with vcr.use_cassette('test_credentials_create_success'): - cred = api.credentials.create('Example Cred', 'SSH', - username='root', - password='something', - auth_method='password', - elevate_privileges_with='Nothing', - custom_password_prompt='') + cred = api.credentials.create( + 'Example Cred', + 'SSH', + username='root', + password='something', + auth_method='password', + elevate_privileges_with='Nothing', + custom_password_prompt='', + ) def teardown(): try: @@ -156,81 +152,45 @@ def teardown(): def test_credentials_create_cred_name_typeerror(api): - '''test to raise the exception when type of credential name is not as defined''' + """test to raise the exception when type of credential name is not as defined""" with pytest.raises(TypeError): api.credentials.create(1, 'something') def test_credentials_create_cred_type_typeerror(api): - '''test to raise the exception when type of credential type is not as defined''' + """test to raise the exception when type of credential type is not as defined""" with pytest.raises(TypeError): api.credentials.create('something', 1) def test_credentials_create_description_typeerror(api): - '''test to raise the exception when type of description is not as defined''' + """test to raise the exception when type of description is not as defined""" with pytest.raises(TypeError): api.credentials.create('something', 'something', 1) @pytest.mark.vcr() def test_credentials_create_success(cred): - '''test to create the credential''' + """test to create the credential""" single(cred, 'uuid') -def test_credentials_delete_id_typeerror(api): - '''test to raise the exception when type of id is not as defined''' - with pytest.raises(TypeError): - api.credentials.delete(1) - - -def test_credentials_delete_id_unexpectedvalueerror(api): - '''test to raise the exception when value of id is not as defined''' - with pytest.raises(UnexpectedValueError): - api.credentials.delete('something') - - @pytest.mark.vcr() def test_credentials_delete_success(api, cred): - '''test to delete the credential''' + """test to delete the credential""" api.credentials.delete(cred) -def test_credentials_edit_id_typeerror(api): - '''test to raise the exception when type of id is not as defined''' - with pytest.raises(TypeError): - api.credentials.edit(1) - - -def test_credentials_edit_id_unexpectedvalueerror(api): - '''test to raise the exception when value of id is not as defined''' - with pytest.raises(UnexpectedValueError): - api.credentials.edit('something') - - @pytest.mark.vcr() def test_credentials_edit_success(api, cred): - '''test to edit the credentials''' + """test to edit the credentials""" creds = api.credentials.edit(cred, cred_name='updated cred') assert isinstance(creds, bool) -def test_credentials_details_id_typeerror(api): - '''test to raise the exception when type of id is not as defined''' - with pytest.raises(TypeError): - api.credentials.details(1) - - -def test_credentials_details_id_unexpectedvalueerror(api): - '''test to raise the exception when value of id is not as defined''' - with pytest.raises(UnexpectedValueError): - api.credentials.details('something') - - @pytest.mark.vcr() def test_credentials_details_success(api, cred): - '''test to get the details of the credentials''' + """test to get the details of the credentials""" creds = api.credentials.details(cred) assert isinstance(creds, dict) check(creds, 'name', str) @@ -256,100 +216,100 @@ def test_credentials_details_success(api, cred): @pytest.mark.vcr() def test_credentials_list_offset_typeerror(api): - '''test to raise the exception when type of offset is not as defined''' + """test to raise the exception when type of offset is not as defined""" with pytest.raises(TypeError): api.credentials.list(offset='nope') @pytest.mark.vcr() def test_credentials_list_limit_typeerror(api): - '''test to raise the exception when type of limit is not as defined''' + """test to raise the exception when type of limit is not as defined""" with pytest.raises(TypeError): api.credentials.list(limit='nope') @pytest.mark.vcr() def test_credentials_list_sort_field_typeerror(api): - '''test to raise the exception when type of sort_field are not as defined''' + """test to raise the exception when type of sort_field are not as defined""" with pytest.raises(TypeError): api.credentials.list(sort=((1, 'asc'),)) @pytest.mark.vcr() def test_credentials_list_sort_direction_typeerror(api): - '''test to raise the exception when type of sort direction are not as defined''' + """test to raise the exception when type of sort direction are not as defined""" with pytest.raises(TypeError): api.credentials.list(sort=(('uuid', 1),)) @pytest.mark.vcr() def test_credentials_list_sort_direction_unexpectedvalue(api): - ''' + """ test to raise the exception when value of sort_direction are not as defined - ''' + """ with pytest.raises(UnexpectedValueError): api.credentials.list(sort=(('uuid', 'nope'),)) @pytest.mark.vcr() def test_credentials_list_filter_name_typeerror(api): - ''' + """ test to raise the exception when type of filter name are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list((1, 'match', 'win')) @pytest.mark.vcr() def test_credentials_list_filter_operator_typeerror(api): - ''' + """ test to raise the exception when type of filter operator are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list(('name', 1, 'win')) @pytest.mark.vcr() def test_credentials_list_filter_value_typeerror(api): - ''' + """ test to raise the exception when type of filter value are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list(('name', 'match', 1)) @pytest.mark.vcr() def test_credentials_list_filter_type_typeerror(api): - ''' + """ test to raise the exception when type of filter type are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list(filter_type=1) @pytest.mark.vcr() def test_credentials_list_wildcard_typeerror(api): - ''' + """ test to raise the exception when type of wildcard are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list(wildcard=1) @pytest.mark.vcr() def test_credentials_list_wildcard_fields_typeerror(api): - ''' + """ test to raise the exception when type of wildcard_fields are not as defined - ''' + """ with pytest.raises(TypeError): api.credentials.list(wildcard_fields='nope') @pytest.mark.vcr() def test_credentials_list(api): - ''' + """ test to get credentials list - ''' + """ count = 0 credentials = api.credentials.list() for cred in credentials: @@ -378,37 +338,39 @@ def test_credentials_list(api): @pytest.mark.vcr() def test_credentials_upload_type_error(api): - ''' + """ test to raise error when the type param is not set. - ''' + """ # Create a BytesIO object - file = io.BytesIO(b'\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64') + file = io.BytesIO(b'\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64') with pytest.raises(TypeError): api.credentials.upload(file) + @pytest.mark.vcr() def test_credentials_upload_should_fail_on_unsupported_type(api): - ''' + """ test to raise error when the type param is not set. - ''' + """ # Create a BytesIO object - file = io.BytesIO(b'\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64') + file = io.BytesIO(b'\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64') with pytest.raises(UnexpectedValueError): - api.credentials.upload(file, "some_type") + api.credentials.upload(file, 'some_type') + @pytest.mark.vcr() def test_credentials_upload_should_succeed(api): - ''' + """ test to succeed only when all the values are set properly. - ''' + """ # Create a BytesIO object - file = io.BytesIO(b'\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64') - uploaded_file = api.credentials.upload(file, "pem") + file = io.BytesIO(b'\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64') + uploaded_file = api.credentials.upload(file, 'pem') assert type(uploaded_file) is str @@ -452,13 +414,14 @@ def test_credentials_list_fields(api, scan): test to check the list of credentials and their types """ count = 0 - credentials = api.credentials.list(filter_type='or', - limit=45, - offset=2, - wildcard='match', - wildcard_fields=['name'], - owner_uuid=scan['owner_uuid'] - ) + credentials = api.credentials.list( + filter_type='or', + limit=45, + offset=2, + wildcard='match', + wildcard_fields=['name'], + owner_uuid=scan['owner_uuid'], + ) for creds in credentials: count += 1 assert isinstance(creds, dict) @@ -485,8 +448,17 @@ def test_credentials_list_fields(api, scan): @pytest.mark.vcr() def test_credentials_edit_with_permissions(api, cred): - '''test to edit with permissions parameter in the credentials''' - creds = api.credentials.edit('f07267e2-2994-4293-900a-8acc9adaacd4', cred_name='updated cred', - permissions=[{'grantee_uuid': '00000000-0000-0000-0000-000000000000', 'type': 'user', - 'permissions': 64, 'name': 'test_admin@pytenable.io'}]) + """test to edit with permissions parameter in the credentials""" + creds = api.credentials.edit( + 'f07267e2-2994-4293-900a-8acc9adaacd4', + cred_name='updated cred', + permissions=[ + { + 'grantee_uuid': '00000000-0000-0000-0000-000000000000', + 'type': 'user', + 'permissions': 64, + 'name': 'test_admin@pytenable.io', + } + ], + ) assert isinstance(creds, bool) diff --git a/tests/io/test_editor.py b/tests/io/test_editor.py index 7d713d066..ea0dd549e 100644 --- a/tests/io/test_editor.py +++ b/tests/io/test_editor.py @@ -7,7 +7,7 @@ import pytest from tenable.base.endpoint import APIEndpoint -from tenable.errors import NotFoundError, UnexpectedValueError +from tenable.errors import NotFoundError ### ### As the editor endpoints are really meant to drive the UI, the tests here diff --git a/tests/io/test_exclusions.py b/tests/io/test_exclusions.py index c7b3b2580..16fb2a599 100644 --- a/tests/io/test_exclusions.py +++ b/tests/io/test_exclusions.py @@ -15,7 +15,6 @@ UnexpectedValueError, ) from tests.checker import check -from tests.io.test_networks import fixture_network from tests.pytenable_log_handler import log_exception @@ -581,24 +580,6 @@ def test_exclusions_delete_standard_user_fail(stdapi, exclusion): stdapi.exclusions.delete(exclusion['id']) -@pytest.mark.vcr() -def test_exclusions_edit_no_exclusion_id_typeerror(api): - """ - test to raise exception when exclusion_id is not provided. - """ - with pytest.raises(TypeError): - api.exclusions.edit() - - -@pytest.mark.vcr() -def test_exclusions_edit_exclusion_id_typeerror(api): - """ - test to raise exception when type of exclusion_id param does not match the expected type. - """ - with pytest.raises(TypeError): - api.exclusions.edit('nope') - - @pytest.mark.vcr() def test_exclusions_edit_members_typeerror(api, exclusion): """ diff --git a/tests/io/test_file.py b/tests/io/test_file.py index d635053b2..cb6dec9cb 100644 --- a/tests/io/test_file.py +++ b/tests/io/test_file.py @@ -1,8 +1,6 @@ """ test for uploading the file and file with encryption """ -import os -import pytest import responses from io import BytesIO from responses import matchers diff --git a/tests/io/test_folders.py b/tests/io/test_folders.py index 02e2ea452..a947379b3 100644 --- a/tests/io/test_folders.py +++ b/tests/io/test_folders.py @@ -1,47 +1,36 @@ -''' +""" test folders -''' +""" + import uuid -import pytest -from ..checker import check +import pytest -@pytest.mark.vcr() -def test_folders_folder_name_typeerror(api): - '''test to raise the exception when type of folder_name is not as defined''' - with pytest.raises(TypeError): - api.folders.create(1) +from ..checker import check @pytest.mark.vcr() def test_folders_create(folder): - '''test to create folder''' + """test to create folder""" assert isinstance(folder, int) @pytest.mark.vcr() def test_folders_delete(api, folder): - '''test to delete a folder''' + """test to delete a folder""" api.folders.delete(folder) assert folder not in [f['id'] for f in api.folders.list()] -@pytest.mark.vcr() -def test_folders_edit_name_typeerror(api, folder): - '''test to raise the exception when type of name is not as defined''' - with pytest.raises(TypeError): - api.folders.edit(folder, 1) - - @pytest.mark.vcr() def test_folders_edit(api, folder): - '''test to raise the exception when type of chunk_size is not as defined''' + """test to raise the exception when type of chunk_size is not as defined""" api.folders.edit(folder, str(uuid.uuid4())[:20]) @pytest.mark.vcr() def test_folders_list(api): - '''test to list the folders''' + """test to list the folders""" folders = api.folders.list() assert isinstance(folders, list) for data in folders: diff --git a/tests/io/test_groups.py b/tests/io/test_groups.py index cc71c7b06..fc53f555f 100644 --- a/tests/io/test_groups.py +++ b/tests/io/test_groups.py @@ -1,23 +1,27 @@ -''' +""" test groups -''' +""" + import uuid + import pytest + from tenable.errors import NotFoundError from tests.checker import check from tests.pytenable_log_handler import log_exception + @pytest.fixture(name='group') def fixture_group(request, api): - ''' + """ Fixture to create group - ''' + """ group = api.groups.create(str(uuid.uuid4())) def teardown(): - ''' + """ cleanup function to delete group - ''' + """ try: api.groups.delete(group['id']) except NotFoundError as err: @@ -28,79 +32,48 @@ def teardown(): return group -@pytest.mark.vcr() -def test_groups_create_name_typeerror(api): - ''' - test to raise exception when type of name param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.create(1) - - @pytest.mark.vcr() def test_groups_create(group): - ''' + """ test to create group - ''' + """ assert isinstance(group, dict) check(group, 'uuid', 'uuid') check(group, 'name', str) check(group, 'id', int) -@pytest.mark.vcr() -def test_groups_delete_id_typerror(api): - ''' - test to raise exception when type of id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.delete('nothing') - @pytest.mark.vcr() def test_groups_delete_notfounderror(api): - ''' + """ test to raise exception when group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.groups.delete(1) @pytest.mark.vcr() def test_groups_delete_success(api, group): - ''' + """ test to delete group - ''' + """ api.groups.delete(group['id']) -@pytest.mark.vcr() -def test_groups_edit_id_typeerror(api): - ''' - test to raise exception when type of id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.edit('nope', 'something') - -@pytest.mark.vcr() -def test_groups_edit_name_typeerror(api): - ''' - test to raise exception when type of name param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.edit(1, 1) @pytest.mark.vcr() def test_groups_edit_notfounderror(api): - ''' + """ test to raise exception when group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.groups.edit(1, 'newname') + @pytest.mark.vcr() def test_groups_edit_success(api, group): - ''' + """ test to edit group - ''' + """ edited = api.groups.edit(group['id'], 'New Example Name') assert isinstance(edited, dict) check(edited, 'uuid', 'uuid') @@ -108,11 +81,12 @@ def test_groups_edit_success(api, group): check(edited, 'user_count', int) check(edited, 'id', int) + @pytest.mark.vcr() def test_groups_list(api): - ''' + """ test to get list of group - ''' + """ groups = api.groups.list() assert isinstance(groups, list) for group in groups: @@ -122,27 +96,21 @@ def test_groups_list(api): check(group, 'user_count', int) check(group, 'uuid', 'uuid') -@pytest.mark.vcr() -def test_groups_list_users_id_typeerror(api): - ''' - test to raise exception when type of id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.list_users('nope') @pytest.mark.vcr() def test_groups_list_users_notfound(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.groups.list_users(1) + @pytest.mark.vcr() def test_groups_list_users_success(api, group, user): - ''' + """ test to get list of users in group - ''' + """ api.groups.add_user(group['id'], user['id']) users = api.groups.list_users(group['id']) assert isinstance(users, list) @@ -159,71 +127,37 @@ def test_groups_list_users_success(api, group, user): check(usr, 'username', str) check(usr, 'uuid_id', 'uuid') -@pytest.mark.vcr() -def test_group_add_user_to_group_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.add_user('nope', 1) - - -@pytest.mark.vcr() -def test_groups_add_user_to_group_user_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.add_user(1, 'nope') - @pytest.mark.vcr() def test_groups_add_user_to_group_notfounderror(api): - ''' + """ test to raise exception when user_id or group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.groups.add_user(1, 1) @pytest.mark.vcr() def test_groups_add_user_to_group_success(api, group, user): - ''' + """ test to add user group - ''' + """ api.groups.add_user(group['id'], user['id']) -@pytest.mark.vcr() -def test_groups_delete_user_from_group_group_id_tyupeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.delete_user('nope', 1) - - -@pytest.mark.vcr() -def test_groups_delete_user_from_group_user_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.groups.delete_user(1, 'nope') - @pytest.mark.vcr() def test_groups_delete_user_from_group_notfounderror(api): - ''' + """ test to raise exception when user_id or group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.groups.delete_user(1, 1) @pytest.mark.vcr() def test_groups_delete_user_from_group_success(api, group, user): - ''' + """ test to delete user from group - ''' + """ api.groups.add_user(group['id'], user['id']) - api.groups.delete_user(group['id'], user['id']) \ No newline at end of file + api.groups.delete_user(group['id'], user['id']) diff --git a/tests/io/test_networks.py b/tests/io/test_networks.py index 9ce3c67b9..0fd87bb72 100644 --- a/tests/io/test_networks.py +++ b/tests/io/test_networks.py @@ -1,57 +1,20 @@ -''' +""" test networks -''' -import uuid -import pytest -from tenable.errors import UnexpectedValueError, APIError, BadRequestError -from tests.checker import check -from tests.pytenable_log_handler import log_exception - - -@pytest.fixture(name='network') -def fixture_network(request, api, vcr): - ''' - Fixture to create network - ''' - with vcr.use_cassette('test_networks_create_success'): - network = api.networks.create('Network-{}'.format(uuid.uuid4())) - - def teardown(): - ''' - cleanup function to delete network - ''' - try: - with vcr.use_cassette('test_networks_delete_success'): - api.networks.delete(network['uuid']) - except APIError as err: - log_exception(err) - pass - - request.addfinalizer(teardown) - return network +""" +import uuid -def test_networks_create_name_typeerror(api): - ''' - test to raise exception when type of name param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.create(1, 'something') - +import pytest -def test_networks_create_description_typeerror(api): - ''' - test to raise exception when type of description param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.create('something', 1) +from tenable.errors import BadRequestError, UnexpectedValueError +from tests.checker import check @pytest.mark.vcr() def test_networks_create_success(network): - ''' + """ test to create network. - ''' + """ assert isinstance(network, dict) check(network, 'owner_uuid', 'uuid') check(network, 'created', int) @@ -67,51 +30,19 @@ def test_networks_create_success(network): check(network, 'modified_in_seconds', int) -def test_networks_delete_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.delete(1) - - -def test_networks_delete_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.delete('something') - - @pytest.mark.vcr() def test_networks_delete_success(api, network): - ''' + """ test to delete network. - ''' + """ api.networks.delete(network['uuid']) -def test_networks_details_id_typeerror(api): - ''' - test to raise exception when type of network param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.details(1) - - -def test_networks_details_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.details('something') - - @pytest.mark.vcr() def test_networks_details_success(api, network): - ''' + """ test to get details of specified network. - ''' + """ resp = api.networks.details(network['uuid']) assert isinstance(resp, dict) check(resp, 'owner_uuid', 'uuid') @@ -128,43 +59,27 @@ def test_networks_details_success(api, network): check(resp, 'modified_in_seconds', int) -def test_networks_edit_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.edit(1, 'something') - - -def test_networks_edit_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.edit('something', 'something') - - def test_networks_edit_name_typeerror(api): - ''' + """ test to raise exception when type of name param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.edit(str(uuid.uuid4()), 1) def test_networks_edit_description_typeerror(api): - ''' + """ test to raise exception when type of description param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.edit(str(uuid.uuid4()), 'something', 1) @pytest.mark.vcr() def test_networks_edit_success(api, network): - ''' + """ test to update the specified network resource. - ''' + """ resp = api.networks.edit(network['uuid'], 'New Name - {}'.format(uuid.uuid4())) assert isinstance(resp, dict) check(resp, 'owner_uuid', 'uuid') @@ -181,27 +96,11 @@ def test_networks_edit_success(api, network): check(resp, 'modified_in_seconds', int) -def test_networks_list_scanners_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.list_scanners(1) - - -def test_networks_list_scanners_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.list_scanners('something') - - @pytest.mark.vcr() def test_networks_list_scanners_success(api): - ''' + """ test to get list of scanners associated to given network. - ''' + """ scanners = api.networks.list_scanners('00000000-0000-0000-0000-000000000000') assert isinstance(scanners, list) for scanner in scanners: @@ -215,27 +114,11 @@ def test_networks_list_scanners_success(api): check(scanner, 'group', bool) -def test_networks_unassigned_scanners_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.unassigned_scanners(1) - - -def test_networks_unassigned_scanners_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.unassigned_scanners('something') - - @pytest.mark.vcr() def test_networks_unassigned_scanners_success(api, network): - ''' + """ test to get the list of scanners that are currently unassigned to the given network - ''' + """ scanners = api.networks.unassigned_scanners(network['uuid']) assert isinstance(scanners, list) for scanner in scanners: @@ -249,162 +132,129 @@ def test_networks_unassigned_scanners_success(api, network): check(scanner, 'group', bool) -def test_networks_assign_scanners_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.assign_scanners(1, str(uuid.uuid4())) - - -def test_networks_assign_scanners_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.assign_scanners('something', str(uuid.uuid4())) - - -def test_networks_assign_scanners_scanner_id_typeerror(api): - ''' - test to raise exception when type of scanner_uuis param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.assign_scanners(str(uuid.uuid4()), 1) - - -def test_networks_assign_scanners_scanner_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of scanner_uuid param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.assign_scanners(str(uuid.uuid4()), 'something') - - @pytest.mark.vcr() def test_networks_assign_scanners_success(api, network, vcr): - ''' + """ test to assign scanners to network. - ''' + """ with vcr.use_cassette('test_networks_list_scanners_success'): - scanner = api.networks.list_scanners( - '00000000-0000-0000-0000-000000000000')[0] + scanner = api.networks.list_scanners('00000000-0000-0000-0000-000000000000')[0] api.networks.assign_scanners(network['uuid'], scanner['uuid']) @pytest.mark.vcr() def test_networks_list_offset_typeerror(api): - ''' + """ test to raise exception when type of offset param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(offset='nope') @pytest.mark.vcr() def test_networks_list_limit_typeerror(api): - ''' + """ test to raise exception when type of limit param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(limit='nope') @pytest.mark.vcr() def test_networks_list_sort_field_typeerror(api): - ''' + """ test to raise exception when type of sort field param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(sort=((1, 'asc'),)) @pytest.mark.vcr() def test_networks_list_sort_direction_typeerror(api): - ''' + """ test to raise exception when type of sort direction param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(sort=(('uuid', 1),)) @pytest.mark.vcr() def test_networks_list_sort_direction_unexpectedvalue(api): - ''' + """ test to raise exception when value of sort direction param does not match the choices. - ''' + """ with pytest.raises(UnexpectedValueError): api.networks.list(sort=(('uuid', 'nope'),)) @pytest.mark.vcr() def test_networks_list_filter_name_typeerror(api): - ''' + """ test to raise exception when type of filter_name param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list((1, 'match', 'win')) @pytest.mark.vcr() def test_networks_list_filter_operator_typeerror(api): - ''' + """ test to raise exception when type of filter_operator param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(('name', 1, 'win')) @pytest.mark.vcr() def test_networks_list_filter_value_typeerror(api): - ''' + """ test to raise exception when type of filter_value param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(('name', 'match', 1)) @pytest.mark.vcr() def test_networks_list_filter_type_typeerror(api): - ''' + """ test to raise exception when type of filter_type param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(filter_type=1) @pytest.mark.vcr() def test_networks_list_wildcard_typeerror(api): - ''' + """ test to raise exception when type of wildcard param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(wildcard=1) @pytest.mark.vcr() def test_networks_list_wildcard_fields_typeerror(api): - ''' + """ test to raise exception when type of wildcard_fields param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(wildcard_fields='nope') @pytest.mark.vcr() def test_networks_list_include_deleted_typeerror(api): - ''' + """ test to raise exception when type of include_deleted param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.networks.list(include_deleted='nope') @pytest.mark.vcr() def test_networks_list(api): - ''' + """ test to get list of configured networks. - ''' + """ count = 0 networks = api.networks.list() for network in networks: @@ -424,47 +274,20 @@ def test_networks_list(api): assert count == networks.total -@pytest.mark.vcr() -def test_network_asset_count_network_id_typeerror(api): - ''' - test to raise exception when type of network_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.network_asset_count(1, 180) - - -@pytest.mark.vcr() -def test_network_asset_count_network_id_unexpectedvalueerror(api): - ''' - test to raise exception when value of network_id param does not match the expected pattern. - ''' - with pytest.raises(UnexpectedValueError): - api.networks.network_asset_count('nope', 180) - - -@pytest.mark.vcr() -def test_network_asset_count_network_num_days_typeerror(api): - ''' - test to raise exception when type of num_days param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.networks.network_asset_count('00000000-0000-0000-0000-000000000000', 'nope') - - @pytest.mark.vcr() def test_network_asset_count_network_num_days_invalidinputerror(api): - ''' + """ test to raise exception when value of num_days param is not valid. - ''' + """ with pytest.raises(BadRequestError): api.networks.network_asset_count('00000000-0000-0000-0000-000000000000', -180) @pytest.mark.vcr() def test_network_asset_count_network_success(api): - ''' + """ test to raise exception when type of network_id param does not match the expected type. - ''' + """ network = '00000000-0000-0000-0000-000000000000' resp = api.networks.network_asset_count(network, 180) assert isinstance(resp, dict) @@ -474,35 +297,37 @@ def test_network_asset_count_network_success(api): @pytest.mark.vcr() def test_networks_assign_multiple_scanners_success(api, network, scanner): - ''' + """ test to pass multiple scanners - ''' + """ scanner = api.networks.list_scanners('00000000-0000-0000-0000-000000000000')[0] api.networks.assign_scanners(network['uuid'], scanner['uuid'], scanner['uuid']) @pytest.mark.vcr() def test_networks_unexpectedvalueerror(api, network): - ''' + """ test to raise exception when scanner_uuids are not passed - ''' + """ with pytest.raises(UnexpectedValueError): api.networks.assign_scanners(network['uuid']) @pytest.mark.vcr() def test_networks_list_fileds(api): - ''' + """ test to get list of configured networks. - ''' + """ count = 0 - networks = api.networks.list(filter_type='or', - include_deleted=True, - offset=2, - limit=50, - wildcard='match', - wildcard_fields=['name']) + networks = api.networks.list( + filter_type='or', + include_deleted=True, + offset=2, + limit=50, + wildcard='match', + wildcard_fields=['name'], + ) for network in networks: assert isinstance(network, dict) check(network, 'owner_uuid', 'uuid') @@ -517,9 +342,12 @@ def test_networks_list_fileds(api): check(network, 'created_in_seconds', int) check(network, 'modified_in_seconds', int) + def test_network_create_assets_ttl_days_typeerror(api): with pytest.raises(TypeError): - api.networks.create('New Name - {}'.format(uuid.uuid4()), 'something', 'something') + api.networks.create( + 'New Name - {}'.format(uuid.uuid4()), 'something', 'something' + ) @pytest.mark.vcr() @@ -532,15 +360,17 @@ def test_network_create_assets_ttl_days_invalid_input_error(api): def test_network_edit_assets_ttl_days_type_error(api): with pytest.raises(TypeError): network = api.networks.create('Network-{}'.format(uuid.uuid4())) - api.networks.edit(network_id=network['uuid'], - name='New Name - {}'.format(uuid.uuid4()), - assets_ttl_days='something') + api.networks.edit( + network_id=network['uuid'], + name='New Name - {}'.format(uuid.uuid4()), + assets_ttl_days='something', + ) @pytest.mark.vcr() def test_network_edit_assets_ttl_days_invalid_input_error(api): with pytest.raises(BadRequestError): network = api.networks.create('Network-{}'.format(uuid.uuid4())) - api.networks.edit(network_id=network['uuid'], - name='something', - assets_ttl_days=-5) + api.networks.edit( + network_id=network['uuid'], name='something', assets_ttl_days=-5 + ) diff --git a/tests/io/test_permissions.py b/tests/io/test_permissions.py index c738ef566..b9c45d337 100644 --- a/tests/io/test_permissions.py +++ b/tests/io/test_permissions.py @@ -1,6 +1,4 @@ from tenable.errors import * -from ..checker import check, single -import pytest ### ### The permissions module is leveraged exclusively by the diff --git a/tests/io/test_plugins.py b/tests/io/test_plugins.py index db10e26c9..45b8e0cab 100644 --- a/tests/io/test_plugins.py +++ b/tests/io/test_plugins.py @@ -1,15 +1,19 @@ -''' +""" test plugins -''' +""" + from datetime import date + import pytest + from tenable.io.plugins import PluginIterator + from ..checker import check @pytest.mark.vcr() def test_families(api): - '''test to get the plugin families''' + """test to get the plugin families""" families = api.plugins.families() assert isinstance(families, list) for family in families: @@ -18,16 +22,9 @@ def test_families(api): check(family, 'name', str) -@pytest.mark.vcr() -def test_family_details_family_id_typeerror(api): - '''test to raise the exception when parameter is not passed of expected type''' - with pytest.raises(TypeError): - api.plugins.family_details('nope') - - @pytest.mark.vcr() def test_family_details(api): - '''test to get the family details''' + """test to get the family details""" data = api.plugins.family_details(27) assert isinstance(data, dict) check(data, 'name', str) @@ -39,16 +36,9 @@ def test_family_details(api): assert data['id'] == 27 -@pytest.mark.vcr() -def test_plugin_details_plugin_id_typerror(api): - '''test to raise the exception when parameter is not passed of expected type''' - with pytest.raises(TypeError): - api.plugins.plugin_details('nope') - - @pytest.mark.vcr() def test_plugin_details(api): - '''test to get the plugin details''' + """test to get the plugin details""" detail = api.plugins.plugin_details(19506) assert isinstance(detail, dict) check(detail, 'attributes', list) @@ -63,39 +53,36 @@ def test_plugin_details(api): @pytest.mark.vcr() def test_plugins_list_page_typeerror(api): - '''test to raise the exception when parameter is not passed of expected type''' + """test to raise the exception when parameter is not passed of expected type""" with pytest.raises(TypeError): api.plugins.list(page='one') @pytest.mark.vcr() def test_plugins_list_size_typeerror(api): - '''test to raise the exception when parameter is not passed of expected type''' + """test to raise the exception when parameter is not passed of expected type""" with pytest.raises(TypeError): api.plugins.list(size='one') @pytest.mark.vcr() def test_plugins_list_last_updated_date_typeerror(api): - '''test to raise the exception when parameter is not passed of expected type''' + """test to raise the exception when parameter is not passed of expected type""" with pytest.raises(TypeError): api.plugins.list(last_updated=1) @pytest.mark.vcr() def test_plugins_list_num_pages_typeerror(api): - '''test to raise the exception when parameter is not passed of expected type''' + """test to raise the exception when parameter is not passed of expected type""" with pytest.raises(TypeError): api.plugins.list(num_pages='one') @pytest.mark.vcr() def test_plugins_list_success(api): - '''test to get the plugins list''' - plugins = api.plugins.list( - last_updated=date(2019, 1, 1), - num_pages=2, - size=10) + """test to get the plugins list""" + plugins = api.plugins.list(last_updated=date(2019, 1, 1), num_pages=2, size=10) for plugin in plugins: check(plugin, 'attributes', dict) check(plugin['attributes'], 'description', str) @@ -109,17 +96,14 @@ def test_plugins_list_success(api): @pytest.mark.vcr() def test_plugin_iterator_populate_family_cache(api): - '''test for _populate_family_cache in PluginIterator''' + """test for _populate_family_cache in PluginIterator""" getattr(PluginIterator(api), '_populate_family_cache')() @pytest.mark.vcr() def test_plugins_populate_family_cache_with_maptable(api): - '''test next method in PluginIterator''' - plugins = api.plugins.list( - last_updated=date(2019, 1, 1), - num_pages=1, - size=4) + """test next method in PluginIterator""" + plugins = api.plugins.list(last_updated=date(2019, 1, 1), num_pages=1, size=4) plugins._maptable = {'plugins': {12122: 13, 12050: 13}, 'families': {13: 'Netware'}} for plugin in plugins: check(plugin, 'name', str) diff --git a/tests/io/test_policies.py b/tests/io/test_policies.py index 04c52d945..82e3b03c1 100644 --- a/tests/io/test_policies.py +++ b/tests/io/test_policies.py @@ -1,44 +1,30 @@ -''' +""" test policies -''' -import io -import pytest -from tenable.errors import NotFoundError, UnexpectedValueError -from ..checker import check +""" +import io -@pytest.mark.vcr() -def test_configure_id_typeerror(api): - ''' - test to raise the exception when type of id is not as defined - ''' - with pytest.raises(TypeError): - api.policies.configure('nope', dict()) +import pytest +from tenable.errors import NotFoundError, UnexpectedValueError -@pytest.mark.vcr() -def test_configure_policy_typeerror(api): - ''' - test to raise the exception when type of policy is not as defined - ''' - with pytest.raises(TypeError): - api.policies.configure(1, 'nope') +from ..checker import check @pytest.mark.vcr() def test_configure_policy_notfounderror(api): - ''' + """ test to raise the exception when a policy to be configured is not found - ''' + """ with pytest.raises(NotFoundError): api.policies.configure(1, dict()) @pytest.mark.vcr() def test_configure_policy(api, policy): - ''' + """ test to configure the policy - ''' + """ details = api.policies.details(policy['policy_id']) details['settings']['name'] = 'MODIFIED' api.policies.configure(policy['policy_id'], details) @@ -46,29 +32,20 @@ def test_configure_policy(api, policy): assert updated['settings']['name'] == 'MODIFIED' -@pytest.mark.vcr() -def test_copy_policy_id_typeerror(api): - ''' - test to raise the exception when type of policy_id is not as defined - ''' - with pytest.raises(TypeError): - api.policies.copy('nope') - - @pytest.mark.vcr() def test_copy_policy_notfounderror(api): - ''' + """ test to raise the exception when the policy to be copied is not found - ''' + """ with pytest.raises(NotFoundError): api.policies.copy(1) @pytest.mark.vcr() def test_copy_policy(api, policy): - ''' + """ test to copy the policy - ''' + """ new = api.policies.copy(policy['policy_id']) assert isinstance(new, dict) check(new, 'id', int) @@ -79,100 +56,72 @@ def test_copy_policy(api, policy): @pytest.mark.vcr() def test_create_policy(api, policy): - '''test to check types of a policy''' + """test to check types of a policy""" assert isinstance(policy, dict) check(policy, 'policy_id', int) check(policy, 'policy_name', str) -@pytest.mark.vcr() -def test_delete_policy_id_typeerror(api): - ''' - test to raise the exception when type of policy_id is not as defined - ''' - with pytest.raises(TypeError): - api.policies.delete('nope') - - @pytest.mark.vcr() def test_delete_policy_notfounderror(api): - ''' + """ test to raise the exception when policy to be deleted is not found - ''' + """ with pytest.raises(NotFoundError): api.policies.delete(1) @pytest.mark.vcr() def test_delete_policy(api, policy): - ''' + """ test to delete the policy - ''' + """ api.policies.delete(policy['policy_id']) -@pytest.mark.vcr() -def test_policy_details_id_typeerror(api): - ''' - test to raise the exception when the type of id is - not of the expected type - ''' - with pytest.raises(TypeError): - api.policies.details('nope') - - @pytest.mark.vcr() def test_policy_details_notfounderror(api): - ''' + """ test to raise the exception when the details of the policy is not found - ''' + """ with pytest.raises(NotFoundError): api.policies.details(1) @pytest.mark.vcr() def test_policy_details(api, policy): - ''' + """ test to get the policy details - ''' + """ policy = api.policies.details(policy['policy_id']) assert isinstance(policy, dict) check(policy, 'uuid', 'scanner-uuid') check(policy, 'settings', dict) -@pytest.mark.vcr() -def test_policy_export_id_typeerror(api): - ''' - test to raise the exception when type of export id is not as defined - ''' - with pytest.raises(TypeError): - api.policies.policy_export('nope') - - @pytest.mark.vcr() def test_policy_export_notfounderror(api): - ''' + """ test to raise the exception when the policy to be exported is not found - ''' + """ with pytest.raises(NotFoundError): api.policies.policy_export(1) @pytest.mark.vcr() def test_policy_export(api, policy): - ''' + """ test to export the policy data - ''' + """ pobj = api.policies.policy_export(policy['policy_id']) assert isinstance(pobj, io.BytesIO) @pytest.mark.vcr() def test_policy_import(api, policy): - ''' + """ test to import the policy - ''' + """ pobj = api.policies.policy_export(policy['policy_id']) resp = api.policies.policy_import(pobj) assert isinstance(resp, dict) @@ -191,9 +140,9 @@ def test_policy_import(api, policy): @pytest.mark.vcr() def test_policy_list(api, policy): - ''' + """ test to get the policy list - ''' + """ policies = api.policies.list() assert isinstance(policies, list) for pol in policies: @@ -213,9 +162,9 @@ def test_policy_list(api, policy): @pytest.mark.vcr() def test_policy_template_details_success(api): - ''' + """ test to get the template details - ''' + """ template_detail = api.policies.template_details('agent_advanced') assert isinstance(template_detail, dict) check(template_detail, 'compliance', dict) @@ -226,27 +175,27 @@ def test_policy_template_details_success(api): @pytest.mark.vcr() def test_policy_template_details_keyerror(api): - ''' + """ test to raise the exception when key of template details is not as defined - ''' + """ with pytest.raises(UnexpectedValueError): api.policies.template_details('one') @pytest.mark.vcr() def test_policy_template_details_typeerror(api): - ''' + """ test to raise the exception when type of details is not as defined - ''' + """ with pytest.raises(TypeError): api.policies.template_details(1) @pytest.mark.vcr() def test_policies_template_details_new_success(api): - ''' + """ test to get template details - ''' + """ templates = api.policies.templates() for keys in templates.keys(): api.policies.template_details(keys) @@ -254,9 +203,9 @@ def test_policies_template_details_new_success(api): @pytest.mark.vcr() def test_policies_template_details_credentials_types_settings_success(api): - ''' + """ test to cover settings data from template details - ''' + """ template_detail = api.policies.template_details('asv') assert isinstance(template_detail, dict) check(template_detail, 'credentials', dict) diff --git a/tests/io/test_scanner_groups.py b/tests/io/test_scanner_groups.py index 4f0e286e6..dee9c3e25 100644 --- a/tests/io/test_scanner_groups.py +++ b/tests/io/test_scanner_groups.py @@ -1,88 +1,88 @@ -''' +""" test scanner_groups -''' +""" + import uuid + import pytest -from tenable.errors import BadRequestError, ForbiddenError, \ - NotFoundError, UnexpectedValueError, ServerError -from tests.checker import check -@pytest.mark.vcr() -def test_add_scanner_to_group_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.add_scanner('nope', 1) +from tenable.errors import ( + BadRequestError, + ForbiddenError, + NotFoundError, + ServerError, + UnexpectedValueError, +) +from tests.checker import check -@pytest.mark.vcr() -def test_add_scanner_to_group_scanner_id_typeerror(api): - ''' - test to raise exception when type of scanner_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.add_scanner(1, 'nope') @pytest.mark.vcr() def test_add_scanner_to_scanner_group_notfounderror(api): - ''' + """ test to raise exception when scanner_id or group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.scanner_groups.add_scanner(1, 1) + @pytest.mark.vcr() def test_add_scanner_to_scanner_group_permissionerror(stdapi): - ''' + """ test to raise exception when standard user try to add scanner to group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.add_scanner(1, 1) + @pytest.mark.vcr() def test_add_scanner_to_group(api, scanner, scannergroup): - ''' + """ test to add scanner to scanner_group - ''' + """ api.scanner_groups.add_scanner(scannergroup['id'], scanner['id']) + @pytest.mark.vcr() def test_create_scanner_group_name_typeerror(api): - ''' + """ test to raise exception when type of name param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.scanner_groups.create(1) + @pytest.mark.vcr() def test_create_scanner_group_type_typeerror(api): - ''' + """ test to raise exception when type of group_type param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.scanner_groups.create(str(uuid.uuid4()), group_type=1) + @pytest.mark.vcr() def test_create_scanner_group_type_unexpectedvalue(api): - ''' + """ test to raise exception when group_type param value does not match the choices. - ''' + """ with pytest.raises(UnexpectedValueError): api.scanner_groups.create(str(uuid.uuid4()), group_type='normal') + @pytest.mark.vcr() def test_create_scanner_group_permissionerror(stdapi): - ''' + """ test to raise exception when standard user try to create scanner group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.create(str(uuid.uuid4())) + @pytest.mark.vcr() def test_create_scanner_group(scannergroup): - ''' + """ test to create scanner_group - ''' + """ assert isinstance(scannergroup, dict) scanner_group = scannergroup check(scanner_group, 'default_permissions', int) @@ -97,107 +97,84 @@ def test_create_scanner_group(scannergroup): check(scanner_group, 'type', str) check(scanner_group, 'uuid', 'uuid') -@pytest.mark.vcr() -def test_delete_scanner_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.delete('nope') @pytest.mark.vcr() def test_delete_scanner_group_notfound(api): - ''' + """ test to raise exception when user provided group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.scanner_groups.delete(1) + @pytest.mark.vcr() def test_delete_scanner_group_permissionserror(stdapi): - ''' + """ test to raise exception when standard user try to delete scanner group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.delete(1) + @pytest.mark.vcr() def test_delete_scanner_group(api, scannergroup): - ''' + """ test to delete scanner_group - ''' + """ api.scanner_groups.delete(scannergroup['id']) -@pytest.mark.vcr() -def test_remove_scanner_from_group_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.delete_scanner('nope', 1) - -@pytest.mark.vcr() -def test_remove_scanner_from_group_scanner_id_typeerror(api): - ''' - test to raise exception when type of scanner_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.delete_scanner(1, 'nope') @pytest.mark.vcr() def test_remove_scanner_from_scanner_group_notfounderror(api): - ''' + """ test to raise exception when scanner_id or group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.scanner_groups.delete_scanner(1, 1) + @pytest.mark.vcr() def test_remove_scanner_from_scanner_group_permissionserror(stdapi): - ''' + """ test to raise exception when standard user try to remove scanner from scanner group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.delete_scanner(1, 1) + @pytest.mark.vcr() def test_remove_scanner_from_scanner_group(api, scanner, scannergroup): - ''' + """ test to remove scanner from scanner group - ''' + """ api.scanner_groups.add_scanner(scannergroup['id'], scanner['id']) api.scanner_groups.delete_scanner(scannergroup['id'], scanner['id']) -@pytest.mark.vcr() -def test_scannergroup_details_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.details('nope') @pytest.mark.vcr() @pytest.mark.xfail(raises=ServerError) def test_scannergroup_details_notfounderror(api): - ''' + """ test to raise exception when group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.scanner_groups.details(1) + @pytest.mark.vcr() def test_scannergroup_details_permissionerror(stdapi): - ''' + """ test to raise exception when standard user try to get details of scanner group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.details(1) + @pytest.mark.vcr() def test_scannergroup_details(api, scannergroup): - ''' + """ test to get details of scanner group. - ''' + """ scanner_group = api.scanner_groups.details(scannergroup['id']) assert scanner_group['id'] == scannergroup['id'] scanner_group = scannergroup @@ -213,51 +190,39 @@ def test_scannergroup_details(api, scannergroup): check(scanner_group, 'type', str) check(scanner_group, 'uuid', 'uuid') -@pytest.mark.vcr() -def test_edit_scanner_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.edit('nope', str(uuid.uuid4())) - -@pytest.mark.vcr() -def test_edit_scanner_group_name_typeerror(api): - ''' - test to raise exception when type of name param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.edit(1, 1) @pytest.mark.vcr() @pytest.mark.xfail(raises=ServerError) def test_edit_scanner_group_notfounderror(api): - ''' + """ test to raise exception when group_id not found. - ''' + """ with pytest.raises(NotFoundError): api.scanner_groups.edit(1, str(uuid.uuid4())) + @pytest.mark.vcr() def test_edit_scanner_group_permissionerror(stdapi): - ''' + """ test to raise exception when standard user try to edit name of scanner group. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.edit(1, str(uuid.uuid4())) + @pytest.mark.vcr() def test_edit_scanner_group(api, scannergroup): - ''' + """ test to edit scanner group - ''' + """ api.scanner_groups.edit(scannergroup['id'], str(uuid.uuid4())) + @pytest.mark.vcr() def test_list_scanner_groups(api): - ''' + """ test to list scanner group - ''' + """ groups = api.scanner_groups.list() assert isinstance(groups, list) for group in groups: @@ -279,35 +244,30 @@ def test_list_scanner_groups(api): check(group, 'user_permissions', int) check(group, 'uuid', 'uuid') + @pytest.mark.vcr() def test_list_scanner_groups_permissionerror(stdapi): - ''' + """ test to raise exception when standard user try to get list of scanner groups. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.list() -@pytest.mark.vcr() -def test_list_scanners_in_scanner_group_id_typeerror(api): - ''' - test to raise exception when type of group_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.scanner_groups.list_scanners('nope') @pytest.mark.vcr() def test_list_scanners_in_scanner_group_permissionerror(stdapi, scannergroup): - ''' + """ test to raise exception when standard user try to get list of scanners in scanner groups. - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanner_groups.list_scanners(scannergroup['id']) + @pytest.mark.vcr() def test_list_scanners_in_scanner_group(api, scannergroup, scanner): - ''' + """ test to get list of scanners in scanner group - ''' + """ api.scanner_groups.add_scanner(scannergroup['id'], scanner['id']) scanners = api.scanner_groups.list_scanners(scannergroup['id']) assert isinstance(scanners, list) @@ -342,34 +302,38 @@ def test_list_scanners_in_scanner_group(api, scannergroup, scanner): check(scanner_detail, 'uuid', 'uuid') api.scanner_groups.delete_scanner(scannergroup['id'], scanner['id']) + @pytest.mark.vcr() def test_edit_routes_in_scanner_group_invalidinputerror(api, scannergroup): - ''' + """ test to raise exception when values in routes are invalid - ''' + """ with pytest.raises(BadRequestError): api.scanner_groups.edit_routes(scannergroup['id'], ['127.0.0.256']) + @pytest.mark.vcr() def test_edit_routes_in_scanner_group_typeerror(api, scannergroup): - ''' + """ test to raise exception when type of routes param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.scanner_groups.edit_routes(scannergroup['id'], '127.0.0.1') + @pytest.mark.vcr() def test_edit_routes_in_scanner_group_success(api, scannergroup): - ''' + """ test to edit routes in scanner group - ''' + """ api.scanner_groups.edit_routes(scannergroup['id'], ['127.0.0.1']) + @pytest.mark.vcr() def test_list_routes_in_scanner_group_success(api, scannergroup): - ''' + """ test to list routes in scanner group - ''' + """ api.scanner_groups.edit_routes(scannergroup['id'], ['127.0.0.1']) routes = api.scanner_groups.list_routes(scannergroup['id']) assert routes[0]['route'] == '127.0.0.1' diff --git a/tests/io/test_scanners.py b/tests/io/test_scanners.py index 3b3e6756e..532a7394b 100644 --- a/tests/io/test_scanners.py +++ b/tests/io/test_scanners.py @@ -1,137 +1,103 @@ -''' +""" test scanners -''' -import uuid -import pytest -from tenable.errors import NotFoundError, UnexpectedValueError, ForbiddenError -from ..checker import check +""" +import uuid -@pytest.mark.vcr() -def test_scanner_control_scans_scanner_id_typeerror(api): - ''' - test to raise the exception when the type of field scanner_id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.control_scan('nope', str(uuid.uuid4()), 'stop') +import pytest +from tenable.errors import ForbiddenError, NotFoundError, UnexpectedValueError -@pytest.mark.vcr() -def test_scanner_control_scans_scan_uuid_typeerror(api): - ''' - test to raise the exception when the type of field scan_uuid is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.control_scan(1, 1, 'stop') +from ..checker import check @pytest.mark.vcr() def test_scanner_control_scans_action_typeerror(api): - ''' + """ test to raise the exception when the type of field action is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.control_scan(1, str(uuid.uuid4()), 1) @pytest.mark.vcr() def test_scanner_control_scans_action_unexpectedvalue(api): - ''' + """ test to raise the exception when performed action on the scanner which is not found - ''' + """ with pytest.raises(UnexpectedValueError): api.scanners.control_scan(1, str(uuid.uuid4()), 'nope') @pytest.mark.vcr() def test_scanner_control_scans_notfounderror(api): - ''' + """ test to raise the exception when the standard user performs actions against scanner which is not found - ''' + """ with pytest.raises(NotFoundError): - api.scanners.control_scan(1, - 'c5e3e4c9-ee47-4fbc-9e1d-d6f39801f56c', 'stop') + api.scanners.control_scan(1, 'c5e3e4c9-ee47-4fbc-9e1d-d6f39801f56c', 'stop') @pytest.mark.vcr() def test_scanner_control_scans_permissionerror(stdapi): - ''' + """ test to raise the exception when standard user performs actions against given scanner - ''' + """ with pytest.raises(ForbiddenError): - stdapi.scanners.control_scan(1, - 'c5e3e4c9-ee47-4fbc-9e1d-d6f39801f56c', 'stop') - - -@pytest.mark.vcr() -def test_scanner_delete_id_typeerror(api): - ''' - test to raise the exception when the type of field id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.delete('nope') + stdapi.scanners.control_scan(1, 'c5e3e4c9-ee47-4fbc-9e1d-d6f39801f56c', 'stop') @pytest.mark.vcr() def test_scanner_delete_notfound(api): - ''' + """ test to raise the exception when the id is not found to delete the scanner - ''' + """ with pytest.raises(NotFoundError): api.scanners.delete(1) @pytest.mark.vcr() def test_scanner_delete_permissionerror(stdapi, scanner): - ''' + """ test to raise the exception when the standard user gets when tried to delete the scanner - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.delete(scanner['id']) @pytest.mark.skip(reason="We don't want to actually delete scanners.") def test_scanner_delete(api, scanner): - ''' + """ test to delete the scanners - ''' + """ api.scanners.delete(scanner['id']) -@pytest.mark.vcr() -def test_scanner_details_id_typeerror(api): - ''' - test to raise the exception when the type of field id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.details('nope') - - @pytest.mark.vcr() def test_scanner_details_notfounderror(api): - ''' + """ test to raise the exception when the details of the scanners not found - ''' + """ with pytest.raises(NotFoundError): api.scanners.details(1) @pytest.mark.vcr() def test_scanner_details_permissionerror(stdapi, scanner): - ''' + """ test to raise the exception when standatd user tries to get the details - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.details(scanner['id']) @pytest.mark.vcr() def test_scanner_details(api, scanner): - ''' + """ test to get the scanner details - ''' + """ each_scanner = api.scanners.details(scanner['id']) check(each_scanner, 'id', int) check(each_scanner, 'uuid', 'scanner-uuid') @@ -146,56 +112,47 @@ def test_scanner_details(api, scanner): check(each_scanner, 'pool', bool) -@pytest.mark.vcr() -def test_scanner_edit_id_typeerror(api): - ''' - test to raise the exception when the type of field id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.edit('nope') - - @pytest.mark.vcr() def test_sanner_edit_plugin_update_typeerror(api, scanner): - ''' + """ test to raise the exception when the type of field force_plugin_update is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.edit(scanner['id'], force_plugin_update='yup') @pytest.mark.vcr() def test_scanner_edit_ui_update_typeerror(api, scanner): - ''' + """ test to raise the exception when the type of field force_ui_update is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.edit(scanner['id'], force_ui_update='yup') @pytest.mark.vcr() def test_scanner_edit_finish_update_typeerror(api, scanner): - ''' + """ test to raise the exception when the type of field finish_update is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.edit(scanner['id'], finish_update='yup') @pytest.mark.vcr() def test_scanner_edit_registration_code_typeerror(api, scanner): - ''' + """ test to raise the exception when the type of field registration_code is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.edit(scanner['id'], registration_code=False) @pytest.mark.vcr() def test_scanner_edit_aws_update_typeerror(api, scanner): - ''' + """ test to raise the exception when the type of field aws update interval is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.edit(scanner['id'], aws_update_interval='nope') @@ -203,18 +160,18 @@ def test_scanner_edit_aws_update_typeerror(api, scanner): @pytest.mark.vcr() @pytest.mark.xfail(raises=ForbiddenError) def test_scanner_edit_notfounderror(api): - ''' + """ test to raise the exception when tried to edit the scanner which is not found - ''' + """ with pytest.raises(NotFoundError): api.scanners.edit(1, force_ui_update=True) @pytest.mark.vcr() def test_scanner_edit_permissionserror(stdapi, scanner): - ''' + """ test to raise the exception when standard user gets when tried to edit the scanners - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.edit(scanner['id'], force_ui_update=True) @@ -222,26 +179,17 @@ def test_scanner_edit_permissionserror(stdapi, scanner): @pytest.mark.vcr() @pytest.mark.xfail(raises=ForbiddenError) def test_scanner_edit(api, scanner): - ''' + """ test to raise the exception when doing the edit scanner operation - ''' + """ api.scanners.edit(scanner['id'], force_plugin_update=True) -@pytest.mark.vcr() -def test_scanner_get_aws_targets_id_typeerror(api): - ''' - test to raise the exception when the type of field id in aws targets is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.get_aws_targets('nope') - - @pytest.mark.vcr() def test_scanner_get_aws_targets_notfounderror(api): - ''' + """ test to raise the exception when aws targets are not found - ''' + """ with pytest.raises(NotFoundError): api.scanners.get_aws_targets(1) @@ -249,131 +197,104 @@ def test_scanner_get_aws_targets_notfounderror(api): @pytest.mark.vcr() @pytest.mark.xfail(raises=NotFoundError) def test_scanner_get_aws_targets_permissionerror(stdapi): - ''' + """ test to raise the exception when standard user gets the aws targets - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.get_aws_targets(1) -@pytest.mark.skip(reason="No AWS Environment to test against.") +@pytest.mark.skip(reason='No AWS Environment to test against.') @pytest.mark.vcr() def test_scanner_get_aws_targets(api, scanner): - ''' + """ test to get aws targets - ''' + """ pass -@pytest.mark.vcr() -def test_scanner_key_id_typeerror(api): - ''' - test to raise the exception when the type of field scanner_key id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.get_scanner_key('nope') - - @pytest.mark.vcr() def test_scanner_key(api, scanner): - ''' + """ test to verify the instance of scanner key - ''' + """ assert isinstance(api.scanners.get_scanner_key(scanner['id']), str) -@pytest.mark.vcr() -def test_get_scans_id_typeerror(api): - ''' - test to raise the exception when the type of field id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.get_scans('nope') - - @pytest.mark.vcr() def test_get_scans_notfounderror(api): - ''' + """ test to raise the exception when the scans are not found - ''' + """ with pytest.raises(NotFoundError): api.scanners.get_scans(1) @pytest.mark.vcr() def test_get_scans_permissionerror(stdapi, scanner): - ''' + """ test to raise the exception when the standard user gets the scans - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.get_scans(scanner['id']) @pytest.mark.vcr() def test_get_scans(api, scanner): - ''' + """ test to verify the instance of the scans - ''' + """ assert isinstance(api.scanners.get_scans(scanner['id']), list) @pytest.mark.vcr() def test_list_scanners_permissionerror(stdapi): - ''' + """ test to raise the exception when standard user gets the list of scanners - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.list() @pytest.mark.vcr() def test_list_scanners(api): - ''' + """ test to check the instance of list of scanners - ''' + """ assert isinstance(api.scanners.list(), list) -@pytest.mark.vcr() -def test_link_state_id_typeerror(api): - ''' - test to raise the exception when the type of field id is not as defined - ''' - with pytest.raises(TypeError): - api.scanners.toggle_link_state('nope', True) - - @pytest.mark.vcr() def test_link_state_linked_typeerror(api): - ''' + """ test to raise the exception when the type of field linked is not as defined - ''' + """ with pytest.raises(TypeError): api.scanners.toggle_link_state(1, 'nope') @pytest.mark.vcr() def test_link_state_permissionerror(stdapi, scanner): - ''' + """ test to raise the exception when standard user toggle the link state - ''' + """ with pytest.raises(ForbiddenError): stdapi.scanners.toggle_link_state(scanner['id'], True) @pytest.mark.vcr() def test_link_state(api, scanner): - ''' + """ test to toggle link state - ''' + """ api.scanners.toggle_link_state(scanner['id'], True) @pytest.mark.vcr() def test_scanners_get_permissions(api, scanner): - ''' + """ test to get the permission - ''' + """ permissions = api.scanners.get_permissions(scanner['id']) assert isinstance(permissions, list) for permission in permissions: @@ -383,18 +304,20 @@ def test_scanners_get_permissions(api, scanner): @pytest.mark.vcr() def test_scanner_edit_permissions(api, scanner, user): - ''' + """ test to edit the permissions - ''' - api.scanners.edit_permissions(scanner['id'], - {'type': 'default', 'permissions': 16}, - {'type': 'user', 'id': user['id'], 'permissions': 16}) + """ + api.scanners.edit_permissions( + scanner['id'], + {'type': 'default', 'permissions': 16}, + {'type': 'user', 'id': user['id'], 'permissions': 16}, + ) @pytest.mark.vcr() def test_scanner_linking_key(api): - ''' + """ test to get the linking key - ''' + """ resp = api.scanners.linking_key() assert isinstance(resp, str) diff --git a/tests/io/test_session.py b/tests/io/test_session.py index b27e2aa3d..b0b697335 100644 --- a/tests/io/test_session.py +++ b/tests/io/test_session.py @@ -1,18 +1,8 @@ -from ..checker import check import uuid -import pytest - - -@pytest.mark.vcr() -def test_session_edit_name_typeerror(api): - with pytest.raises(TypeError): - api.session.edit(1, 'nope') +import pytest -@pytest.mark.vcr() -def test_session_edit_email_typeerror(api): - with pytest.raises(TypeError): - api.session.edit('nope', 1) +from ..checker import check @pytest.mark.vcr() @@ -44,18 +34,6 @@ def test_session_details(api): check(session['features'], item, bool) -@pytest.mark.vcr() -def test_session_change_password_old_password_typeerror(api): - with pytest.raises(TypeError): - api.session.change_password(False, 'nope') - - -@pytest.mark.vcr() -def test_session_change_password_new_password_typeerror(api): - with pytest.raises(TypeError): - api.session.change_password('nope', False) - - @pytest.mark.skip(reason="Don't have old password") def test_session_change_password(api): pass diff --git a/tests/io/test_users.py b/tests/io/test_users.py index a6a2833f2..0be170498 100644 --- a/tests/io/test_users.py +++ b/tests/io/test_users.py @@ -1,84 +1,97 @@ -''' +""" test users -''' +""" + import uuid + import pytest -from tenable.errors import ForbiddenError, NotFoundError, BadRequestError + +from tenable.errors import BadRequestError, ForbiddenError, NotFoundError from tests.checker import check + def guser(): - ''' + """ Returns username - ''' + """ return '{}@tenable.com'.format(uuid.uuid4()) + def gpass(): - ''' + """ Returns password - ''' + """ return '{}Tt!'.format(uuid.uuid4()) + @pytest.mark.vcr() def test_users_create_username_typeerror(api): - ''' + """ test to raise exception when type of username param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(False, gpass(), 1) + @pytest.mark.vcr() def test_users_create_password_typeerror(api): - ''' + """ test to raise exception when type of password param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(guser(), False, 1) + @pytest.mark.vcr() def test_users_create_permissions_typeerror(api): - ''' + """ test to raise exception when type of permissions param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(guser(), gpass(), 'nope') + @pytest.mark.vcr() def test_users_create_name_typeerror(api): - ''' + """ test to raise exception when type of name param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(guser(), gpass(), 1, name=1) + @pytest.mark.vcr() def test_users_create_email_typeerror(api): - ''' + """ test to raise exception when type of email param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(guser(), gpass(), 1, email=1) + @pytest.mark.vcr() def test_users_create_account_type_typeerror(api): - ''' + """ test to raise exception when type of account_type param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.create(guser(), gpass(), 1, account_type=False) + @pytest.mark.vcr() def test_users_create_permissionserror(stdapi): - ''' + """ test to raise exception when standard_user tries to create user. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.create(guser(), gpass(), 16) + @pytest.mark.vcr() def test_users_create(user): - ''' + """ test to create user - ''' + """ assert isinstance(user, dict) check(user, 'email', str) check(user, 'enabled', bool) @@ -91,58 +104,48 @@ def test_users_create(user): check(user, 'username', str) check(user, 'uuid_id', 'uuid') -@pytest.mark.vcr() -def test_users_delete_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.delete('False') @pytest.mark.vcr() def test_users_delete_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.delete(0) + @pytest.mark.vcr() def test_users_delete_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard_user tries to delete user. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.delete(user['id']) + @pytest.mark.vcr() def test_users_delete(api, user): - ''' + """ test to delete user - ''' + """ api.users.delete(user['id']) assert user['id'] not in [u['id'] for u in api.users.list()] -def test_users_details_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.details('nope') @pytest.mark.vcr() def test_users_details_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.details(0) + @pytest.mark.vcr() def test_users_details(api, user): - ''' + """ test to get user details - ''' + """ resp = api.users.details(user['id']) assert isinstance(user, dict) check(resp, 'email', str) @@ -156,67 +159,48 @@ def test_users_details(api, user): check(resp, 'username', str) check(resp, 'uuid_id', 'uuid') -@pytest.mark.vcr() -def test_users_edit_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.edit('nope') - -@pytest.mark.vcr() -def test_users_edit_permissions_typeerror(api): - ''' - test to raise exception when type of permissions param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.edit(1, permissions='nope') - -@pytest.mark.vcr() -def test_users_edit_name_typeerror(api): - ''' - test to raise exception when type of name param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.edit(1, name=1) @pytest.mark.vcr() def test_users_edit_email_typeerror(api): - ''' + """ test to raise exception when type of email param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit(1, email=1) + @pytest.mark.vcr() def test_users_edit_enabled_typeerror(api): - ''' + """ test to raise exception when type of enabled param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit(1, enabled='nope') + @pytest.mark.vcr() def test_users_edit_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.edit(0, email=guser()) + @pytest.mark.vcr() def test_users_edit_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard user try to edit user. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.edit(user['id'], name=str(uuid.uuid4())) + @pytest.mark.vcr() def test_users_edit(api, user): - ''' + """ test to edit user - ''' + """ resp = api.users.edit(user['id'], name='MODDED NAME') assert isinstance(user, dict) check(resp, 'id', int) @@ -231,222 +215,221 @@ def test_users_edit(api, user): check(resp, 'uuid_id', 'uuid') assert resp['name'] == 'MODDED NAME' -@pytest.mark.vcr() -def test_users_enabled_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.enabled('nope', False) @pytest.mark.vcr() def test_users_enabled_enabled_typeerror(api): - ''' + """ test to raise exception when type of enabled param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.enabled(1, 'nope') + @pytest.mark.vcr() def test_users_enabled_notfounderror(api): - ''' + """ test to raise exception when user provided user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.enabled(0, False) + @pytest.mark.vcr() def test_users_enabled_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard user try to disable user. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.enabled(user['id'], False) + @pytest.mark.vcr() def test_users_enabled(api, user): - ''' + """ test to disable user - ''' + """ disabled = api.users.enabled(user['id'], False) assert isinstance(disabled, dict) assert disabled['enabled'] is False -@pytest.mark.vcr() -def test_users_two_factor_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.two_factor('nope', False, False) @pytest.mark.vcr() def test_users_two_factor_email_typeerror(api): - ''' + """ test to raise exception when type of email param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.two_factor(0, False, 'nope') + @pytest.mark.vcr() def test_users_two_factor_sms_typeerror(api): - ''' + """ test to raise exception when type of sms param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.two_factor('nope', False) + @pytest.mark.vcr() def test_users_two_factor_phone_typeerror(api): - ''' + """ test to raise exception when type of phone param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.two_factor(False, False, 8675309) + @pytest.mark.vcr() @pytest.mark.xfail(raises=BadRequestError) def test_users_two_factor(api, user): - ''' + """ test to configure two-factor authorization for a specific user - ''' + """ api.users.two_factor(user['id'], False, False) + @pytest.mark.vcr() def test_users_enable_two_factor_phone_typeerror(api): - ''' + """ test to raise exception when type of phone param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.enable_two_factor(False) + @pytest.mark.vcr() @pytest.mark.skip(reason="Don't want to enable two-facor on the user.") def test_users_enable_two_factor(api): - ''' + """ test to enable two_factor authentication for user. - ''' + """ api.users.enable_two_factor('867-5309') + @pytest.mark.vcr() def test_users_verify_two_factor_code_typeerror(api): - ''' + """ test to raise exception when type of code param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.verify_two_factor(False) + @pytest.mark.vcr() @pytest.mark.skip(reason="Don't want to enable two-factor on the user.") def test_users_verify_two_factor(api): - ''' + """ test to verify two_factor authentication for user. - ''' + """ api.users.verify_two_factor(False) + @pytest.mark.vcr() def test_users_impersonate_id_typeerror(api): - ''' + """ test to raise exception when type of name param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.impersonate(1) api.session.restore() + @pytest.mark.vcr() def test_users_impersonate_notfounderror(api): - ''' + """ test to raise exception when user provided user_id not found. - ''' + """ with pytest.raises(ForbiddenError): api.users.impersonate(guser()) api.session.details() api.session.restore() + @pytest.mark.vcr() def test_users_impersonate_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard user try to impersonate user. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.impersonate(user['username']) stdapi.session.details() stdapi.session.restore() + @pytest.mark.vcr() def test_users_impersonate(api, user): - ''' + """ test to impersonate user - ''' + """ api.users.impersonate(user['username']) assert api.session.details()['username'] == user['username'] api.session.restore() assert api.session.details()['username'] != user['username'] + @pytest.mark.vcr() def test_users_list_users(api, user): - ''' + """ test to list users - ''' + """ users = api.users.list() assert isinstance(users, list) - assert user['id'] in [u['id']for u in users] + assert user['id'] in [u['id'] for u in users] + @pytest.mark.vcr() def test_users_change_password_orig_typeerror(api): - ''' + """ test to raise exception when type of old_password param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.change_password(0, False, 'nope') + @pytest.mark.vcr() def test_users_change_password_new_typeerror(api): - ''' + """ test to raise exception when type of new_password param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.change_password(0, 'nope', False) -@pytest.mark.vcr() -def test_users_change_password_id_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.change_password('fail', 'nope', 'nope') @pytest.mark.vcr() def test_users_change_password_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.change_password(0, 'nope', 'nope') + @pytest.mark.vcr() def test_users_change_password_permissionserror(stdapi, user): - ''' + """ test to raise exception when standard user try to change password. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.change_password(user['id'], 'nope', 'nope') + @pytest.mark.vcr() def test_users_change_password(api): - ''' + """ test to change password - ''' + """ password = gpass() user = api.users.create(guser(), password, 16) api.users.change_password(user['id'], password, gpass()) api.users.delete(user['id']) + @pytest.mark.vcr() def test_users_list_auths_success(api, user): - ''' + """ test to list user auths - ''' + """ user_auth = api.users.list_auths(user['id']) assert isinstance(user, dict) check(user_auth, 'account_uuid', 'uuid') @@ -455,85 +438,86 @@ def test_users_list_auths_success(api, user): check(user_auth, 'saml_permitted', bool) check(user_auth, 'user_uuid', 'uuid') + @pytest.mark.vcr() def test_users_list_auths_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.list_auths(1) -@pytest.mark.vcr() -def test_users_list_auths_typeerror(api): - ''' - test to raise exception when type of user_id param does not match the expected type. - ''' - with pytest.raises(TypeError): - api.users.list_auths('nope') @pytest.mark.vcr() def test_users_list_auths_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard user try to get list of user auths. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.list_auths(user['id']) + @pytest.mark.vcr() def test_users_edit_auths_success(api, user): - ''' + """ test to edit user auths - ''' + """ api.users.edit_auths(user['id'], False, False, False) user_auth = api.users.list_auths(user['id']) assert user_auth['api_permitted'] is False assert user_auth['password_permitted'] is False assert user_auth['saml_permitted'] is False + @pytest.mark.vcr() def test_users_edit_auths_notfounderror(api): - ''' + """ test to raise exception when user_id not found. - ''' + """ with pytest.raises(NotFoundError): api.users.edit_auths(1) + @pytest.mark.vcr() def test_users_edit_auths_typeerror(api): - ''' + """ test to raise exception when type of user_id param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit_auths('nope') + @pytest.mark.vcr() def test_users_edit_auths_api_permitted_typeerror(api, user): - ''' + """ test to raise exception when type of api_permitted param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit_auths(user['id'], api_permitted='nope') + @pytest.mark.vcr() def test_users_edit_auths_password_permitted_typeerror(api, user): - ''' + """ test to raise exception when type of password_permitted param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit_auths(user['id'], password_permitted='nope') + @pytest.mark.vcr() def test_users_edit_auths_saml_permitted_typeerror(api, user): - ''' + """ test to raise exception when type of saml_permitted param does not match the expected type. - ''' + """ with pytest.raises(TypeError): api.users.edit_auths(user['id'], saml_permitted='nope') + @pytest.mark.vcr() def test_users_edit_auths_permissionerror(stdapi, user): - ''' + """ test to raise exception when standard user try to edit user auths. - ''' + """ with pytest.raises(ForbiddenError): stdapi.users.edit_auths(user['id']) diff --git a/tests/io/test_workbenches.py b/tests/io/test_workbenches.py index 5da3b692a..a9bdede29 100644 --- a/tests/io/test_workbenches.py +++ b/tests/io/test_workbenches.py @@ -10,31 +10,6 @@ from tenable.errors import NotFoundError, UnexpectedValueError from tests.checker import check -# @pytest.mark.vcr() -# def test_workbench_assets_age_typeerror(api): -# ''' -# test to raise exception when type of age param does not match the expected type. -# ''' -# with pytest.raises(TypeError): -# api.workbenches.assets(age='onetwothree') -# -# @pytest.mark.vcr() -# def test_workbench_assets_filter_tyype_typeerror(api): -# ''' -# test to raise exception when type of filter param does not match the expected type. -# ''' -# with pytest.raises(TypeError): -# api.workbenches.assets(filter_type=1) -# -# @pytest.mark.vcr() -# def test_workbench_assets_filter_type_unexpectedvalueerror(api): -# ''' -# test to raise exception when filter_type param value does not match the choices. -# ''' -# with pytest.raises(UnexpectedValueError): -# api.workbenches.assets(filter_type='NOT') -# - pytestmark = pytest.mark.filterwarnings('ignore::DeprecationWarning') @@ -117,42 +92,6 @@ def test_workbench_assets(api): check(asset, 'updated_at', 'datetime') -# @pytest.mark.vcr() -# def test_workbench_assets_filtered(api): -# ''' -# test to get filtered workbench assets -# ''' -# assets = api.workbenches.assets(('operating_system', 'match', 'Linux')) -# assert isinstance(assets, list) -# -# -# @pytest.mark.vcr() -# def test_workbench_assets_bad_filter(api): -# ''' -# test to raise exception when any of filter param value does not match the choices. -# ''' -# with pytest.raises(UnexpectedValueError): -# api.workbenches.assets(('operating_system', 'contains', 'Linux')) -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_activity_uuid_typeerror(api): -# ''' -# test to raise exception when type of uuid param does not match the expected type. -# ''' -# with pytest.raises(TypeError): -# api.workbenches.asset_activity(1) -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_activity_uuid_unexpectedvalueerror(api): -# ''' -# test to raise exception when uuid param value does not match the choices. -# ''' -# with pytest.raises(UnexpectedValueError): -# api.workbenches.asset_activity('This should fail') - - @pytest.mark.vcr() def test_workbench_asset_activity(api): """ @@ -198,24 +137,6 @@ def test_workbench_asset_activity(api): print('Activity for the asset uuid is not found') -@pytest.mark.vcr() -def test_workbench_asset_info_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.asset_info(1) - - -@pytest.mark.vcr() -def test_workbench_asset_info_unexpectedvalueerror(api): - """ - test to raise exception when uuid param value does not match the choices. - """ - with pytest.raises(UnexpectedValueError): - api.workbenches.asset_info('abnc-1234-somethinginvalid') - - @pytest.mark.vcr() def test_workbench_asset_info_all_fields_typeerror(api): """ @@ -234,16 +155,6 @@ def test_workbench_asset_info(api): api.workbenches.asset_info(assets[0]['id']) -# @pytest.mark.vcr() -# def test_workbench_asset_vulns_uuid_typeerror(api): -# """ -# test to raise exception when type of uuid param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.asset_vulns(1) -# - - @pytest.mark.vcr() def test_workbench_asset_vulns_age_typeerror(api): """ @@ -331,51 +242,6 @@ def test_workbench_asset_vulns_filtered(api): check(vulnerability, 'vulnerability_state', str) -# @pytest.mark.vcr() -# def test_workbench_asset_vuln_info_uuid_typeerror(api): -# """ -# test to raise exception when type of uuid param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.asset_vuln_info(1, 1) -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_vuln_info_uuid_unexpectedvalueerror(api): -# """ -# test to raise exception when uuid param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.asset_vuln_info('this is not a valid UUID', 1234) -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_vuln_info_plugin_id_typeerror(api): -# """ -# test to raise exception when type of plugin_id param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.asset_vuln_info(str(uuid.uuid4()), 'something here') -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_vuln_info_age_typeerror(api): -# """ -# test to raise exception when type of age param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.asset_vuln_info(str(uuid.uuid4()), 19506, age='none') -# -# -# @pytest.mark.vcr() -# def test_workbench_asset_vuln_info_filter_type_typeerror(api): -# """ -# test to raise exception when type of filter_type param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.asset_vuln_info(str(uuid.uuid4()), 19506, filter_type=123) - - @pytest.mark.vcr() def test_workbench_asset_vuln_info_filter_type_unexpectedvalueerror(api): """ @@ -448,33 +314,6 @@ def test_workbench_asset_vuln_info(api): check(info, 'vuln_count', int) -@pytest.mark.vcr() -def test_workbench_asset_vuln_output_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.asset_vuln_output(1, 1) - - -@pytest.mark.vcr() -def test_workbench_asset_vuln_output_uuid_unexpectedvalueerror(api): - """ - test to raise exception when uuid param value does not match the choices. - """ - with pytest.raises(UnexpectedValueError): - api.workbenches.asset_vuln_output('this is not a valid UUID', 1234) - - -@pytest.mark.vcr() -def test_workbench_asset_vuln_output_plugin_id_typeerror(api): - """ - test to raise exception when type of plugin_id param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.asset_vuln_output(str(uuid.uuid4()), 'something here') - - @pytest.mark.vcr() def test_workbench_asset_vuln_output_age_typeerror(api): """ @@ -577,33 +416,6 @@ def test_workbench_vuln_assets(api): check(asset, key, value) -@pytest.mark.vcr() -def test_workbench_export_asset_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.export(asset_uuid=123) - - -@pytest.mark.vcr() -def test_workbench_export_asset_uuid_unexpectedvalueerror(api): - """ - test to raise exception when uuid param value does not match the choices. - """ - with pytest.raises(UnexpectedValueError): - api.workbenches.export(asset_uuid='something') - - -@pytest.mark.vcr() -def test_workbench_export_plugin_id_typeerror(api): - """ - test to raise exception when type of plugin_id param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.export(plugin_id='something') - - @pytest.mark.vcr() def test_workbench_export_format_typeerror(api): """ @@ -676,108 +488,6 @@ def test_workbench_export(api): assert isinstance(fobj, BytesIO) -# @pytest.mark.vcr() -# def test_workbench_export_plugin_id(api): -# """ -# test workbench export with plugin_id -# """ -# fobj = api.workbenches.export(plugin_id=19506) -# assert isinstance(fobj, BytesIO) - - -# @pytest.mark.vcr() -# def test_workbench_export_asset_uuid(api): -# """ -# test workbench export with asset_uuid -# """ -# assets = api.workbenches.assets() -# fobj = api.workbenches.export(asset_uuid=assets[0]['id']) -# assert isinstance(fobj, BytesIO) -# - -# @pytest.mark.vcr() -# def test_workbench_vulns_age_typeerror(api): -# """ -# test to raise exception when type of age param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(age='none') -# -# -# @pytest.mark.vcr() -# def test_workbench_vulns_filter_type_typeerror(api): -# """ -# test to raise exception when type of filter_type param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(filter_type=123) -# -# -# @pytest.mark.vcr() -# def test_workbench_vulns_filter_type_unexpectedvalueerror(api): -# """ -# test to raise exception when filter_type param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.vulns(filter_type='NOT') -# - -# @pytest.mark.vcr() -# def test_workbench_vulns_invalid_filter(api): -# """ -# test to raise exception when any of filter param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.vulns(('nothing here', 'contains', 'Linux')) -# -# -# @pytest.mark.vcr() -# def test_workbench_vulns_authenticated_typeerror(api): -# """ -# test to raise exception when type of authenticated param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(authenticated='nope') -# -# -# @pytest.mark.vcr() -# def test_workbench_vulns_exploitable_typeerror(api): -# """ -# test to raise exception when type of exploitable param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(exploitable='nope') -# -# -# @pytest.mark.vcr() -# def test_workbench_vulns_resolvable_typeerror(api): -# """ -# test to raise exception when type of resolvable param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(resolvable='nope') - - -# @pytest.mark.vcr() -# def test_workbench_vulns_severity_typeerror(api): -# """ -# test to raise exception when type of severity param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vulns(severity=['low']) -# - - -# @pytest.mark.vcr() -# def test_workbench_vulns_severity_unexpectedvalueerror(api): -# """ -# test to raise exception when severity param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.vulns(severity='something else') -# - - @pytest.mark.vcr() def test_workbench_vulns(api): """ @@ -799,43 +509,6 @@ def test_workbench_vulns(api): check(vulnerability, 'vulnerability_state', str) -# @pytest.mark.vcr() -# def test_workbench_vuln_info_age_typeerror(api): -# """ -# test to raise exception when type of age param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_info(19506, age='none') -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_info_filter_type_typeerror(api): -# """ -# test to raise exception when type of filter_type param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_info(19506, filter_type=123) -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_info_filter_type_unexpectedvalueerror(api): -# """ -# test to raise exception when filter_type param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.vuln_info(19506, filter_type='NOT') -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_info_plugin_id_typeerror(api): -# """ -# test to raise exception when type of plugin_id param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_info('something') -# - - @pytest.mark.vcr() def test_workbench_vuln_info(api): """ @@ -876,43 +549,6 @@ def test_workbench_vuln_info(api): check(info, 'vuln_count', int) -# -# @pytest.mark.vcr() -# def test_workbench_vuln_outputs_age_typeerror(api): -# """ -# test to raise exception when type of age param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_outputs(19506, age='none') -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_outputs_filter_type_typeerror(api): -# """ -# test to raise exception when type of filter_type param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_outputs(19506, filter_type=123) -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_outputs_filter_type_unexpectedvalueerror(api): -# """ -# test to raise exception when filter_type param value does not match the choices. -# """ -# with pytest.raises(UnexpectedValueError): -# api.workbenches.vuln_outputs(19506, filter_type='NOT') -# -# -# @pytest.mark.vcr() -# def test_workbench_vuln_outputs_plugin_id_typeerror(api): -# """ -# test to raise exception when type of plugin_id param does not match the expected type. -# """ -# with pytest.raises(TypeError): -# api.workbenches.vuln_outputs('something') -# -# @pytest.mark.vcr() def test_workbench_vuln_outputs(api): """ @@ -946,15 +582,6 @@ def test_workbench_vuln_outputs(api): check(result, 'transport_protocol', str) -@pytest.mark.vcr() -def test_workbenches_asset_delete_asset_uuid_typeerror(api): - """ - test to raise exception when type of uuid param does not match the expected type. - """ - with pytest.raises(TypeError): - api.workbenches.asset_delete(1) - - @pytest.mark.vcr() @pytest.mark.skip("We don't want to delete asset") def test_workbenches_asset_delete_success(api): diff --git a/tests/nessus/iterators/test_pagination.py b/tests/nessus/iterators/test_pagination.py index afe6a93ca..5d128dc40 100644 --- a/tests/nessus/iterators/test_pagination.py +++ b/tests/nessus/iterators/test_pagination.py @@ -1,4 +1,3 @@ -import pytest import responses from tenable.nessus.iterators.pagination import PaginationIterator diff --git a/tests/nessus/iterators/test_plugins.py b/tests/nessus/iterators/test_plugins.py index cc65c6482..619365175 100644 --- a/tests/nessus/iterators/test_plugins.py +++ b/tests/nessus/iterators/test_plugins.py @@ -1,4 +1,3 @@ -import pytest import re import responses from tenable.nessus.iterators.plugins import PluginIterator diff --git a/tests/nessus/schema/test_pagination.py b/tests/nessus/schema/test_pagination.py index 9deb5040e..9dd0846e9 100644 --- a/tests/nessus/schema/test_pagination.py +++ b/tests/nessus/schema/test_pagination.py @@ -1,4 +1,3 @@ -import pytest from tenable.nessus.schema.pagination import FilterSchema, ListSchema diff --git a/tests/nessus/schema/test_scans.py b/tests/nessus/schema/test_scans.py index 787f5ebd4..cdb2bf683 100644 --- a/tests/nessus/schema/test_scans.py +++ b/tests/nessus/schema/test_scans.py @@ -1,4 +1,3 @@ -import pytest from tenable.nessus.schema.scans import ScanExportSchema diff --git a/tests/nessus/schema/test_settings.py b/tests/nessus/schema/test_settings.py index 98f55ea08..006559b52 100644 --- a/tests/nessus/schema/test_settings.py +++ b/tests/nessus/schema/test_settings.py @@ -1,4 +1,3 @@ -import pytest from tenable.nessus.schema.settings import SettingsSchema, SettingsListSchema diff --git a/tests/nessus/test_agent_groups.py b/tests/nessus/test_agent_groups.py index 7dc8cb497..c27e5b7fd 100644 --- a/tests/nessus/test_agent_groups.py +++ b/tests/nessus/test_agent_groups.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_agents.py b/tests/nessus/test_agents.py index a35377de7..966c0bc92 100644 --- a/tests/nessus/test_agents.py +++ b/tests/nessus/test_agents.py @@ -1,4 +1,3 @@ -import pytest import responses from tenable.nessus.iterators.pagination import PaginationIterator diff --git a/tests/nessus/test_api.py b/tests/nessus/test_api.py index 4cb437767..38729ec01 100644 --- a/tests/nessus/test_api.py +++ b/tests/nessus/test_api.py @@ -1,7 +1,5 @@ -import pytest import responses from tenable.nessus import Nessus -from tenable.errors import AuthenticationWarning @responses.activate diff --git a/tests/nessus/test_editor.py b/tests/nessus/test_editor.py index 0534c7b6f..e58685459 100644 --- a/tests/nessus/test_editor.py +++ b/tests/nessus/test_editor.py @@ -1,4 +1,3 @@ -import pytest import responses from io import BytesIO diff --git a/tests/nessus/test_files.py b/tests/nessus/test_files.py index fb807af24..1e9255423 100644 --- a/tests/nessus/test_files.py +++ b/tests/nessus/test_files.py @@ -1,4 +1,3 @@ -import pytest import responses from responses.matchers import multipart_matcher from io import BytesIO diff --git a/tests/nessus/test_folders.py b/tests/nessus/test_folders.py index 1fc36baa4..e8ef5a750 100644 --- a/tests/nessus/test_folders.py +++ b/tests/nessus/test_folders.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_groups.py b/tests/nessus/test_groups.py index b5f99eb91..941ca080d 100644 --- a/tests/nessus/test_groups.py +++ b/tests/nessus/test_groups.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_mail.py b/tests/nessus/test_mail.py index f2b8e496b..e303fc971 100644 --- a/tests/nessus/test_mail.py +++ b/tests/nessus/test_mail.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_permissions.py b/tests/nessus/test_permissions.py index 5b8e33215..9b796a01e 100644 --- a/tests/nessus/test_permissions.py +++ b/tests/nessus/test_permissions.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_plugin_rules.py b/tests/nessus/test_plugin_rules.py index 5364ae6e1..c123861ce 100644 --- a/tests/nessus/test_plugin_rules.py +++ b/tests/nessus/test_plugin_rules.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_plugins.py b/tests/nessus/test_plugins.py index 725837d64..1ee1cd792 100644 --- a/tests/nessus/test_plugins.py +++ b/tests/nessus/test_plugins.py @@ -1,6 +1,3 @@ -import pytest -import re -import random import responses from tenable.nessus.iterators.plugins import PluginIterator diff --git a/tests/nessus/test_policy.py b/tests/nessus/test_policy.py index 990b71a8c..52b2dd097 100644 --- a/tests/nessus/test_policy.py +++ b/tests/nessus/test_policy.py @@ -1,4 +1,3 @@ -import pytest import responses from responses import matchers from io import BytesIO diff --git a/tests/nessus/test_proxy.py b/tests/nessus/test_proxy.py index 6b78d3b67..7f2be4407 100644 --- a/tests/nessus/test_proxy.py +++ b/tests/nessus/test_proxy.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_scanners.py b/tests/nessus/test_scanners.py index 1952e6428..91062af93 100644 --- a/tests/nessus/test_scanners.py +++ b/tests/nessus/test_scanners.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_scans.py b/tests/nessus/test_scans.py index 1501df64b..ff6c3f543 100644 --- a/tests/nessus/test_scans.py +++ b/tests/nessus/test_scans.py @@ -1,4 +1,3 @@ -import pytest import responses from responses import matchers from io import BytesIO diff --git a/tests/nessus/test_server.py b/tests/nessus/test_server.py index 213a0c726..b6214ce4b 100644 --- a/tests/nessus/test_server.py +++ b/tests/nessus/test_server.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_session.py b/tests/nessus/test_session.py index 9b7ff565c..89bc81420 100644 --- a/tests/nessus/test_session.py +++ b/tests/nessus/test_session.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_settings.py b/tests/nessus/test_settings.py index 44b61b47b..19f4ccf5e 100644 --- a/tests/nessus/test_settings.py +++ b/tests/nessus/test_settings.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_software_update.py b/tests/nessus/test_software_update.py index 923cebf90..c1b90af7f 100644 --- a/tests/nessus/test_software_update.py +++ b/tests/nessus/test_software_update.py @@ -1,4 +1,3 @@ -import pytest import responses diff --git a/tests/nessus/test_users.py b/tests/nessus/test_users.py index ed739f6c5..a2d1562f6 100644 --- a/tests/nessus/test_users.py +++ b/tests/nessus/test_users.py @@ -1,4 +1,3 @@ -import pytest import responses USER = { diff --git a/tests/sc/test___init__.py b/tests/sc/test___init__.py index d37c9c746..df0fb923a 100644 --- a/tests/sc/test___init__.py +++ b/tests/sc/test___init__.py @@ -2,8 +2,6 @@ test file to test various scenarios in init.py """ -import os -import sys import pytest from requests.exceptions import ConnectionError as RequestsConnectionError diff --git a/tests/sc/test_files.py b/tests/sc/test_files.py index 8b5b74e27..2d168a290 100644 --- a/tests/sc/test_files.py +++ b/tests/sc/test_files.py @@ -4,7 +4,6 @@ from io import BytesIO import responses from responses.matchers import json_params_matcher -import pytest @responses.activate diff --git a/tests/sc/test_status.py b/tests/sc/test_status.py index 5bd310e81..cc21e72f4 100644 --- a/tests/sc/test_status.py +++ b/tests/sc/test_status.py @@ -1,5 +1,5 @@ import pytest -from ..checker import check, single +from ..checker import check @pytest.mark.vcr() def test_status_status(admin): diff --git a/tests/sc/test_users.py b/tests/sc/test_users.py index 1fd68a284..7fbd297ed 100644 --- a/tests/sc/test_users.py +++ b/tests/sc/test_users.py @@ -3,7 +3,7 @@ ''' import pytest import responses -from responses.matchers import query_param_matcher, json_params_matcher +from responses.matchers import json_params_matcher from tenable.sc import TenableSC from tenable.errors import APIError, UnexpectedValueError from tests.pytenable_log_handler import log_exception diff --git a/tests/tenableone/attack_path/export/test_export_api.py b/tests/tenableone/attack_path/export/test_export_api.py index 8010bb902..481781ba1 100644 --- a/tests/tenableone/attack_path/export/test_export_api.py +++ b/tests/tenableone/attack_path/export/test_export_api.py @@ -4,7 +4,6 @@ This module tests all public methods of the attack path ExportAPI class. """ -import json import pytest import responses from io import BytesIO diff --git a/tests/tenableone/inventory/export/test_export_api.py b/tests/tenableone/inventory/export/test_export_api.py index b16c750ce..54e9cfb7a 100644 --- a/tests/tenableone/inventory/export/test_export_api.py +++ b/tests/tenableone/inventory/export/test_export_api.py @@ -14,7 +14,6 @@ ExportRequestStatus, ExportStatus, ExportJobsResponse, - ExportJob, ExportType, ) from tenable.tenableone.inventory.schema import SortDirection, PropertyFilter, Operator, Query, QueryMode diff --git a/tests/test_base_restfly_v1.py b/tests/test_base_restfly_v1.py new file mode 100644 index 000000000..419edb7a3 --- /dev/null +++ b/tests/test_base_restfly_v1.py @@ -0,0 +1,1008 @@ +import logging + +import arrow +import pytest +import responses +from box import Box, BoxList +from requests import Response +from requests.adapters import HTTPAdapter +from requests.exceptions import SSLError +from urllib3.exceptions import InsecureRequestWarning +from urllib3.util.retry import Retry + +from tenable import errors +from tenable.base._restfly_v1 import ( + APIEndpoint, + APIIterator, + APISession, + check, + dict_clean, + dict_flatten, + dict_merge, + force_case, + redact_values, + trunc, + url_validator, +) + + +@pytest.fixture +def api(): + return APISession( + url='https://httpbin.org', + vendor='pytest', + product='auto-test', + build='1.5.2-embedded', + ) + + +@pytest.fixture +def e(api): + return APIEndpoint(api) + + +def validate_endpoint(e, api): + assert e._api == api + assert e._log == api._log + + +@responses.activate +def test_endpoint_delete(e): + responses.add(responses.DELETE, 'https://httpbin.org/delete') + resp = e._delete('delete', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_head(e): + responses.add(responses.HEAD, 'https://httpbin.org') + resp = e._head('') + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_get(e): + responses.add(responses.GET, 'https://httpbin.org/get') + resp = e._get('get', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_patch(e): + responses.add(responses.PATCH, 'https://httpbin.org/patch') + resp = e._patch('patch', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_post(e): + responses.add(responses.POST, 'https://httpbin.org/post') + resp = e._post('post', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_put(e): + responses.add(responses.PUT, 'https://httpbin.org/put') + resp = e._put('put', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_endpoint_base_request(e): + responses.add(responses.PUT, 'https://httpbin.org/put', json={'test': 'value'}) + resp = e._req('PUT', 'put', json={'test': 'value'}) + assert isinstance(resp, Response) + + # Test endpoint params: + e._box = True + e._box_attrs = {'default_box': True} + assert isinstance(e._req('PUT', 'put', json={'test': 'value'}), Box) + + e._box = None + e._conv_json = True + assert isinstance(e._req('PUT', 'put', json={'test': 'value'}), dict) + + +@responses.activate +def test_endpoint_path_get(api): + responses.add(responses.GET, 'https://httpbin.org/get') + + class TestAPI(APIEndpoint): + _path = 'get' + + def get(self): + return self._get() + + endpoint = TestAPI(api) + resp = endpoint.get() + assert isinstance(resp, Response) + + +class ExampleIterator(APIIterator): + limit = 10 + offset = 0 + + def _get_page(self): + self.total = 100 + self.page = [{'id': i + self.offset} for i in range(self.limit)] + self.offset += self.limit + + +def test_iterator_stubs(): + assert APIIterator(None)._get_page() is None + + +def test_iterator_get_key(): + items = ExampleIterator(None) + items.next() + assert items.get(0) == {'id': 0} + assert items[0] == {'id': 0} + assert items.get(101, None) is None + + +def test_blank_page(): + class ExIterator(APIIterator): + page = [] + + with pytest.raises(StopIteration): + ExIterator(None).next() + + +def test_iterator(): + items = ExampleIterator(None) + last_item = None + for item in items: + last_item = item + assert last_item == {'id': 99} + assert items.total == 100 + assert items.count == 100 + assert items.num_pages == 10 + assert items.page_count == 10 + assert items.page == [ + {'id': 90}, + {'id': 91}, + {'id': 92}, + {'id': 93}, + {'id': 94}, + {'id': 95}, + {'id': 96}, + {'id': 97}, + {'id': 98}, + {'id': 99}, + ] + + +def test_iterator_max_items(): + items = ExampleIterator(None, max_items=15) + last_item = None + for item in items: + last_item = item + assert last_item == {'id': 14} + assert items.total == 100 + assert items.count == 15 + assert items.num_pages == 2 + assert items.page_count == 5 + assert items.page == [ + {'id': 10}, + {'id': 11}, + {'id': 12}, + {'id': 13}, + {'id': 14}, + {'id': 15}, + {'id': 16}, + {'id': 17}, + {'id': 18}, + {'id': 19}, + ] + + +def test_iterator_max_pages(): + items = ExampleIterator(None, max_pages=2) + last_item = None + for item in items: + last_item = item + assert last_item == {'id': 19} + assert items.total == 100 + assert items.count == 20 + assert items.num_pages == 2 + assert items.page_count == 10 + assert items.page == [ + {'id': 10}, + {'id': 11}, + {'id': 12}, + {'id': 13}, + {'id': 14}, + {'id': 15}, + {'id': 16}, + {'id': 17}, + {'id': 18}, + {'id': 19}, + ] + + +def test_error_repr(): + err = errors.RestflyException('This is a test') + assert str(err) == 'This is a test' + assert err.__repr__() == "'This is a test'" + + +def test_retriable_error(): + assert errors.APIError.retryable is False + errors.APIError.set_retryable(True) + assert errors.APIError.retryable is True + errors.APIError.set_retryable(False) + assert errors.APIError.retryable is False + + +def test_user_agent_string(api): + ua = 'Integration/1.0 (pytest; auto-test; Build/1.5.2-embedded)' + assert ua in api._session.headers['User-Agent'] + + +def test_unexpected_keys_error(): + class Example1(APISession): + _error_on_unexpected_input = True + _url = 'https://httpbin.org' + + class Example2(APISession): + _error_on_unexpected_input = False + _url = 'https://httpbin.org' + + # should raise an error with the invalid KW + with pytest.raises(errors.UnexpectedValueError): + Example1(something=True) + + # Should not raise the error and just ignore it. + Example2(something=True) + + +def test_example_context_manager(): + """ + This test creates a simple "authentication" to validate that the + context management is actually working as expected. As the _authenticate + method is automatically run when entering the context, and _deauthenticate + is run upon exit, we will simply be modifying the authed variable in the + outer scope and asserting that its being properly set. + """ + global authed + + class Example(APISession): + _url = 'https://httpbin.org' + + def _authenticate(self, **kwargs): + global authed + authed = True + + def _deauthenticate(self): + global authed + authed = False + + with Example(): + assert authed is True + assert authed is False + + +def test_session_proxies(): + api = APISession( + url='https://httpbin.org', + vendor='pytest', + product='auto-test', + build='1.5.2-embedded', + proxies={'http': 'localhost:8080'}, + ) + assert api._session.proxies == {'http': 'localhost:8080'} + + +def test_session_ssl_validation(): + api = APISession( + url='https://httpbin.org', + vendor='pytest', + product='auto-test', + build='1.5.2-embedded', + ssl_verify=False, + ) + assert api._session.verify is False + + +def test_client_ssl_cert(): + cert_tuple = ('/path/to/cert.crt', '/path/to/cert.key') + api = APISession( + url='https://httpbin.org', + vendor='pytest', + product='auto-test', + build='1.5.2-embedded', + cert=cert_tuple, + ) + assert api._session.cert == cert_tuple + + +def test_session_adapter(): + retry = Retry( + total=5, + read=5, + connect=5, + backoff_factor=5, + ) + adapter = HTTPAdapter(max_retries=retry) + api = APISession( + url='https://httpbin.org', + vendor='pytest', + product='auto-test', + build='1.5.2-embedded', + adapter=adapter, + ) + assert api._session.get_adapter('https://httpbin.org/') == adapter + + +def test_session_stubs(api): + api._authenticate() + api._deauthenticate() + + +@responses.activate +def test_session_delete(api): + responses.add(responses.DELETE, 'https://httpbin.org/delete') + resp = api.delete('delete', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_session_get(api): + responses.add(responses.GET, 'https://httpbin.org/get') + resp = api.get('get', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_session_patch(api): + responses.add(responses.PATCH, 'https://httpbin.org/patch') + resp = api.patch('patch', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_session_post(api): + responses.add(responses.POST, 'https://httpbin.org/post') + resp = api.post('post', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_session_put(api): + responses.add(responses.PUT, 'https://httpbin.org/put') + resp = api.put('put', json={'test': 'value'}) + assert isinstance(resp, Response) + + +@responses.activate +def test_session_head(api): + responses.add(responses.HEAD, 'https://httpbin.org/') + resp = api.head('') + assert isinstance(resp, Response) + + +@responses.activate +def test_session_redirect(api): + responses.add( + responses.GET, + 'https://httpbin.org/redirect-to', + status=302, + headers={'Location': '/get'}, + ) + responses.add(responses.GET, 'https://httpbin.org/get') + resp = api.get('redirect-to', params={'url': '/get'}) + assert isinstance(resp, Response) + assert resp.url == 'https://httpbin.org/get' + + +@responses.activate +def test_debug_logging(api, caplog): + logger = 'tenable.base._restfly_v1.APISession' + data = {'a': 1, 'b': 2, 'c': {'d': 3, 'e': 4}} + responses.add(responses.POST, 'https://httpbin.org/post', json={'json': data}) + caplog.clear() + with caplog.at_level(logging.DEBUG, logger=logger): + resp = api.post('post', json=data, redact_fields=['a', 'd'], box=False) + assert '"a": "REDACTED"' in caplog.text + assert '"d": "REDACTED"' in caplog.text + assert resp.json()['json'] == data + + caplog.clear() + with caplog.at_level(logging.DEBUG, logger=logger): + api._restricted_paths = ['post'] + resp = api.post('post', json=data, redact_fields=['a', 'd'], box=False) + api._restricted_paths = [] + assert '"params": "REDACTED"' in caplog.text + assert '"body": "REDACTED"' in caplog.text + assert resp.json()['json'] == data + + caplog.clear() + with caplog.at_level(logging.DEBUG, logger=logger): + resp = api.post('post', json=data, box=False) + assert 'REDACTED' not in caplog.text + assert resp.json()['json'] == data + + +@responses.activate +def test_session_full_uri(api): + responses.add(responses.GET, 'https://httpbin.org/get', json={'test': 'value'}) + resp1 = api.get('get').json() + resp2 = api.get('https://httpbin.org/get').json() + assert resp1 == resp2 + + +@responses.activate +def test_session_base_path(api): + responses.add( + responses.POST, 'https://httpbin.org/post', json={'data': {'test': 'value'}} + ) + resp1 = api.post('post', json={'test': 'value'}).json() + api._base_path = 'get' + resp2 = api.post('post', json={'test': 'value'}, use_base=False).json() + resp1['headers'] = {} + resp2['headers'] = {} + assert resp1 == resp2 + + responses.add(responses.GET, 'https://httpbin.org/status/200') + api._base_path = 'status' + api.get('200') + + +@responses.activate +def test_session_retry_after(api): + responses.add( + responses.GET, + 'https://httpbin.org/response-headers', + headers={'Retry-After': '.1'}, + ) + api.get('response-headers', params={'Retry-After': 1}) + + +def test_session_ssl_error(api): + with pytest.raises(SSLError): + api.get('https://self-signed.badssl.com/') + api._ssl_verify = False + with pytest.warns(InsecureRequestWarning): + api.get('https://self-signed.badssl.com/') + + +@responses.activate +def test_session_badrequesterror(api): + responses.add(responses.GET, 'https://httpbin.org/status/400', status=400) + with pytest.raises(errors.BadRequestError): + api.get('status/400') + + +@responses.activate +def test_session_unauthorizederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/401', status=401) + with pytest.raises(errors.UnauthorizedError): + api.get('status/401') + + +@responses.activate +def test_session_forbiddenerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/403', status=403) + with pytest.raises(errors.ForbiddenError): + api.get('status/403') + + +@responses.activate +def test_session_notfounderror(api): + responses.add(responses.GET, 'https://httpbin.org/status/404', status=404) + with pytest.raises(errors.NotFoundError): + api.get('status/404') + + +@responses.activate +def test_session_invalidmethoderror(api): + responses.add(responses.GET, 'https://httpbin.org/status/405', status=405) + with pytest.raises(errors.InvalidMethodError): + api.get('status/405') + + +@responses.activate +def test_session_notacceptableerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/406', status=406) + with pytest.raises(errors.NotAcceptableError): + api.get('status/406') + + +@responses.activate +def test_session_proxyauthenticationerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/407', status=407) + with pytest.raises(errors.ProxyAuthenticationError): + api.get('status/407') + + +@responses.activate +def test_session_requesttimeouterror(api): + responses.add(responses.GET, 'https://httpbin.org/status/408', status=408) + with pytest.raises(errors.RequestTimeoutError): + api.get('status/408') + + +@responses.activate +def test_session_requestconflicterror(api): + responses.add(responses.GET, 'https://httpbin.org/status/409', status=409) + with pytest.raises(errors.RequestConflictError): + api.get('status/409') + + +@responses.activate +def test_session_nolongerexistserror(api): + responses.add(responses.GET, 'https://httpbin.org/status/410', status=410) + with pytest.raises(errors.NoLongerExistsError): + api.get('status/410') + + +@responses.activate +def test_session_lengthrequirederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/411', status=411) + with pytest.raises(errors.LengthRequiredError): + api.get('status/411') + + +@responses.activate +def test_session_preconditionfailederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/412', status=412) + with pytest.raises(errors.PreconditionFailedError): + api.get('status/412') + + +@responses.activate +def test_session_payloadtoolargeerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/413', status=413) + with pytest.raises(errors.PayloadTooLargeError): + api.get('status/413') + + +@responses.activate +def test_session_uritoolongerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/414', status=414) + with pytest.raises(errors.URITooLongError): + api.get('status/414') + + +@responses.activate +def test_session_unsupportedmediatypeerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/415', status=415) + with pytest.raises(errors.UnsupportedMediaTypeError): + api.get('status/415') + + +@responses.activate +def test_session_rangenotsatisfiableerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/416', status=416) + with pytest.raises(errors.RangeNotSatisfiableError): + api.get('status/416') + + +@responses.activate +def test_session_expectationfailederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/417', status=417) + with pytest.raises(errors.ExpectationFailedError): + api.get('status/417') + + +@responses.activate +def test_session_teapotresponseerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/418', status=418) + with pytest.raises(errors.TeapotResponseError): + api.get('status/418') + + +@responses.activate +def test_session_misdirectrequesterror(api): + responses.add(responses.GET, 'https://httpbin.org/status/421', status=421) + with pytest.raises(errors.MisdirectRequestError): + api.get('status/421') + + +@responses.activate +def test_session_tooearlyerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/425', status=425) + with pytest.raises(errors.TooEarlyError): + api.get('status/425') + + +@responses.activate +def test_session_upgraderequirederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/426', status=426) + with pytest.raises(errors.UpgradeRequiredError): + api.get('status/426') + + +@responses.activate +def test_session_preconditionrequirederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/428', status=428) + with pytest.raises(errors.PreconditionRequiredError): + api.get('status/428') + + +@responses.activate +def test_session_toomanyrequests(api): + responses.add( + responses.GET, + 'https://httpbin.org/status/429', + status=429, + headers={'Retry-After': '.1'}, + ) + with pytest.raises(errors.TooManyRequestsError): + api.get('status/429') + + +@responses.activate +def test_session_requestheaderfieldstoolargeerror(api): + responses.add(responses.GET, 'https://httpbin.org/status/431', status=431) + with pytest.raises(errors.RequestHeaderFieldsTooLargeError): + api.get('status/431') + + +@responses.activate +def test_session_unavailableforlegalreasonserror(api): + responses.add(responses.GET, 'https://httpbin.org/status/451', status=451) + with pytest.raises(errors.UnavailableForLegalReasonsError): + api.get('status/451') + + +@responses.activate +def test_session_servererror(api): + responses.add(responses.GET, 'https://httpbin.org/status/500', status=500) + with pytest.raises(errors.ServerError): + api.get('status/500') + + +@responses.activate +def test_session_methodnotimplimentederror(api): + responses.add( + responses.GET, + 'https://httpbin.org/status/501', + status=501, + headers={'Retry-After': '.1'}, + ) + with pytest.raises(errors.MethodNotImplementedError): + api.get('status/501') + + +@responses.activate +def test_session_badgatewayerror(api): + responses.add( + responses.GET, + 'https://httpbin.org/status/502', + status=502, + headers={'Retry-After': '.1'}, + ) + with pytest.raises(errors.BadGatewayError): + api.get('status/502') + + +@responses.activate +def test_session_serviceunavailableerror(api): + responses.add( + responses.GET, + 'https://httpbin.org/status/503', + status=503, + headers={'Retry-After': '.1'}, + ) + with pytest.raises(errors.ServiceUnavailableError): + api.get('status/503') + + +@responses.activate +def test_session_gatewaytimeouterror(api): + responses.add( + responses.GET, + 'https://httpbin.org/status/504', + status=504, + headers={'Retry-After': '.1'}, + ) + with pytest.raises(errors.GatewayTimeoutError): + api.get('status/504') + + +@responses.activate +def test_session_notextendederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/510', status=510) + with pytest.raises(errors.NotExtendedError): + api.get('status/510') + + +@responses.activate +def test_session_networkauthrequirederror(api): + responses.add(responses.GET, 'https://httpbin.org/status/511', status=511) + with pytest.raises(errors.NetworkAuthenticationRequiredError): + api.get('status/511') + + +@responses.activate +def test_session_catchall_error(api): + responses.add(responses.GET, 'https://httpbin.org/status/555', status=555) + with pytest.raises(errors.APIError): + api.get('status/555') + + +@responses.activate +def test_session_box_non_json(api): + html_body = '

Hello World

' + responses.add( + responses.GET, + 'https://httpbin.org/html', + headers={'Content-Type': 'text/html; charset=utf-8'}, + body=html_body, + ) + assert isinstance(api.get('html', box=True), Response) + + +@responses.activate +def test_session_box_json(api): + responses.add(responses.GET, 'https://httpbin.org/json', json={'test': 'value'}) + assert isinstance(api.get('json', box=True), Box) + responses.add( + responses.GET, + 'https://httpbin.org/json', + json=[{'test': 'value'} for _ in range(20)], + ) + assert isinstance(api.get('json', box=True), BoxList) + + +@responses.activate +def test_session_disable_box(api): + responses.add(responses.GET, 'https://httpbin.org/json', json={'test': 'value'}) + assert isinstance(api.get('json', box=False), Response) + + +@responses.activate +def test_session_conv_json_non_json(api): + html_body = '

Hello World

' + responses.add( + responses.GET, + 'https://httpbin.org/html', + headers={'Content-Type': 'text/html; charset=utf-8'}, + body=html_body, + ) + assert isinstance(api.get('html', conv_json=True), Response) + + +@responses.activate +def test_session_conv_json_json(api): + responses.add(responses.GET, 'https://httpbin.org/json', json={'test': 'value'}) + assert isinstance(api.get('json', conv_json=True), dict) + + +@responses.activate +def test_session_disable_conv_json(api): + responses.add(responses.GET, 'https://httpbin.org/json', json={'test': 'value'}) + assert isinstance(api.get('json', conv_json=False), Response) + + +def test_force_case_single(): + assert force_case('TEST', 'lower') == 'test' + assert force_case('test', 'upper') == 'TEST' + + +def test_force_case_list(): + assert force_case(['a', 'b', 'c'], 'upper') == ['A', 'B', 'C'] + assert force_case(['A', 'B', 'C'], 'lower') == ['a', 'b', 'c'] + + +def test_dict_merge(): + with pytest.deprecated_call(): + assert dict_merge({'a': 1}, {'b': 2}) == {'a': 1, 'b': 2} + assert dict_merge({'s': {'a': 1}, 'b': 2}, {'s': {'c': 3, 'a': 4}}) == { + 's': {'a': 4, 'c': 3}, + 'b': 2, + } + assert dict_merge({'a': 1}, {'b': 2}, {'c': 3}, {'a': 5}) == { + 'a': 5, + 'b': 2, + 'c': 3, + } + + +def test_dict_flatten(): + assert dict_flatten({'a': 1, 'b': {'c': 2}}) == {'a': 1, 'b.c': 2} + assert dict_flatten({'A': 1, 'B': {'c': 2}}, lower_key=True) == {'a': 1, 'b.c': 2} + assert dict_flatten({'a': 1, 'b': [{'c': 2, 'd': {'e': 1}}]}) == { + 'a': 1, + 'b': [{'c': 2, 'd.e': 1}], + } + + +def test_dict_clean(): + dirty = { + 'a': 1, + 'b': {'c': 2, 'd': None}, + 'e': None, + 'f': [{'g': 1, 'h': None}, {'i': None}], + 'j': [1, None, {}], + } + assert dict_clean(dirty) == {'a': 1, 'b': {'c': 2}, 'f': [{'g': 1}], 'j': [1, None]} + + +def test_trunc(): + assert trunc('Hello There!', 128) == 'Hello There!' + assert trunc('Too Small', 6) == 'Too...' + assert trunc('Too Small', 3, suffix=None) == 'Too' + + +examples = { + 'uuid': '00000000-0000-0000-0000-000000000000', + 'email': 'someone@company.tld', + 'hex': '1234567890abcdef', + 'url': 'http://company.com/path/of/stuff', + 'ipv4': '192.168.0.1', + 'ipv6': '2001:0db8:0000:0000:0000:ff00:0042:8329', +} + + +def test_check_single_type(): + assert isinstance(check('test', 1, int), int) + + +def test_check_list_items_type(): + assert isinstance(check('test', [1, 2], list, items_type=int), list) + + +def test_check_single_type_soft_checking(): + assert isinstance(check('test', '1', int), int) + + +def test_check_single_type_softcheck_fail(): + with pytest.raises(TypeError): + check('test', '1', int, softcheck=False) + + +def test_check_type_fail(): + with pytest.raises(TypeError): + check('test', 1, str) + + +def test_check_list_items_fail(): + with pytest.raises(TypeError): + check('test', [1, 2, 'three'], list, items_type=int) + + +def test_check_list_items_softcheck(): + assert check('test', [1, 2, '3'], list, items_type=int) == [1, 2, 3] + + +def test_check_choices(): + check('test', [1, 2, 3], list, choices=list(range(5))) + + +def test_check_choices_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', [1, 2, 3, 500], list, choices=list(range(5))) + + +def test_check_pattern_mapping_uuid(): + check('test', examples['uuid'], str, pattern='uuid') + + +def test_check_pattern_mapping_uuid_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, pattern='uuid') + + +def test_check_pattern_mapping_email(): + check('test', examples['email'], str, pattern='email') + + +def test_check_pattern_mapping_email_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, pattern='email') + + +def test_check_pattern_mapping_hex(): + check('test', examples['hex'], str, pattern='hex') + + +def test_check_pattern_mapping_hex_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'something', str, pattern='hex') + + +def test_check_pattern_mapping_url(): + check('test', examples['url'], str, pattern='url') + + +def test_check_pattern_mapping_url_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, pattern='url') + + +def test_check_pattern_mapping_ipv4(): + check('test', examples['ipv4'], str, pattern='ipv4') + + +def test_check_pattern_mapping_ipv4_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, pattern='ipv4') + + +def test_check_pattern_mapping_ipv6(): + check('test', examples['ipv6'], str, pattern='ipv6') + + +def test_check_pattern_mapping_ipv6_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, pattern='ipv6') + + +def test_check_regex_pattern(): + check('test', '12345', str, regex=r'^\d+$') + + +def test_check_pattern_int_pass(): + check('test', 1, (int, str), pattern='ipv4') + + +def test_check_regex_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', 'abcdef', str, regex=r'^\d+$') + + +def test_check_allow_none_fail(): + with pytest.raises(errors.UnexpectedValueError): + check('test', None, str, allow_none=False) + + +def test_arrow_type_inputs(): + arw = arrow.utcnow().floor('hour') + + # Test a floating point + assert check('test', arw.timestamp(), arrow.Arrow) == arw + + # Test an integer + assert check('test', int(arw.timestamp()), arrow.Arrow) == arw + + # Test a string + assert check('test', arw.format(), arrow.Arrow) == arw + + # Test an arrow obj + assert check('test', arw, arrow.Arrow) == arw + + +def test_bool_softchecks(): + assert check('test', 'yes', bool) is True + assert check('test', 'true', bool) is True + assert check('test', 'TRUE', bool) is True + assert check('test', 'false', bool) is False + assert check('test', 'FALSE', bool) is False + assert check('test', 'no', bool) is False + + +def test_return_defaults(): + assert check('test', None, int) is None + assert check('test', None, int, default=1) == 1 + + +def test_pattern_map_failure(): + with pytest.raises(IndexError): + check('test', 'something', str, pattern='something') + + +def test_url_validator(): + assert url_validator('https://google.com') is True + assert ( + url_validator('https://httpbin.org/404', validate=['scheme', 'netloc', 'path']) + is True + ) + assert ( + url_validator('https://httpbin.org', validate=['scheme', 'netloc', 'path']) + is False + ) + assert url_validator('httpbin.org') is False + + +def test_redact_values(): + test = {'a': 1, 'b': 2, 'c': 3, 'd': {'e': 4, 'f': 5, 'g': 6}} + assert redact_values(test, keys=['a', 'c', 'd', 'e']) == { + 'a': 'REDACTED', + 'b': 2, + 'c': 'REDACTED', + 'd': {'e': 'REDACTED', 'f': 5, 'g': 6}, + } + assert redact_values(test) == test diff --git a/tests/test_installable.py b/tests/test_installable.py index 3f82c665c..7e39c4d8a 100644 --- a/tests/test_installable.py +++ b/tests/test_installable.py @@ -2,29 +2,29 @@ def test_import_io_pkg(): - from tenable.io import TenableIO + pass def test_import_sc_pkg(): - from tenable.sc import TenableSC + pass def test_import_ad_pkg(): - from tenable.ie import TenableIE + pass @pytest.mark.skip def test_import_nessus_pkg(): - from tenable.nessus import Nessus + pass def test_import_dl_pkg(): - from tenable.dl import Downloads + pass def test_import_reports_pkg(): - from tenable.reports.nessusv2 import NessusReportv2 + pass def test_import_ot_pkg(): - from tenable.ot import TenableOT + pass diff --git a/tests/test_utils_scrub.py b/tests/test_utils_scrub.py new file mode 100644 index 000000000..951b58099 --- /dev/null +++ b/tests/test_utils_scrub.py @@ -0,0 +1,21 @@ +from uuid import UUID + +from tenable.utils import scrub + + +def test_scrub_string(): + assert 'test' == scrub('test') + + +def test_scrub_int(): + assert '1' == scrub(1) + + +def test_scrub_uuid(): + assert '12345678-1234-1234-1234-123456789012' == scrub( + UUID('12345678-1234-1234-1234-123456789012') + ) + + +def test_scrub_remove_path_traversal(): + assert 'test' == scrub('../test') diff --git a/uv.lock b/uv.lock index f809f08aa..a8863b67a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", - "python_full_version < '3.11'", + "python_full_version < '3.15'", ] [[package]] @@ -48,7 +47,6 @@ name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -150,40 +148,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "backports-datetime-fromisoformat" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, - { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, - { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, - { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, - { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, - { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, - { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, - { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, - { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, - { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, - { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, - { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, - { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, - { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, - { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -199,15 +163,15 @@ wheels = [ [[package]] name = "blessed" -version = "1.41.0" +version = "1.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinxed" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/fa/b8ae1cfc5e255a58f54e0c3871b84c7b0077c3d12320e78d25963b810d45/blessed-1.41.0.tar.gz", hash = "sha256:5f4214e7076313fe85c37263e76bb47668d2877a7045f609a998d65d4126a184", size = 14026215, upload-time = "2026-05-19T23:44:30.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/48/27ed9ee1a574c96453a20f2024d385ec4c33ffeef180faab744c666e7dcd/blessed-1.42.0.tar.gz", hash = "sha256:34b460b77562ed21f807cfd7c527b983b0cc300c98810c8076f283b7bcd45ba7", size = 14025805, upload-time = "2026-05-20T16:03:18.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/3b/913a5a02fd15e8740b606a653e5954c164f76e65f0169e57b77c4230da3c/blessed-1.41.0-py3-none-any.whl", hash = "sha256:cfee8f5833e69bdebfc6231835d07ad52ab766bdd2e91117a6a9bac58b4b5c63", size = 129937, upload-time = "2026-05-19T23:44:27.907Z" }, + { url = "https://files.pythonhosted.org/packages/d2/46/c41f906f2488e14ab65ac77101f9511779fb28c653484bd79ac13f2f21ee/blessed-1.42.0-py3-none-any.whl", hash = "sha256:f96c4a6dc664b48e0b832fa732acc16df67abd30f0ec35babf99025982f21852", size = 129863, upload-time = "2026-05-20T16:03:15.622Z" }, ] [[package]] @@ -216,13 +180,11 @@ version = "0.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "curtsies" }, - { name = "cwcwidth", version = "0.1.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cwcwidth", version = "0.1.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cwcwidth" }, { name = "greenlet" }, { name = "pygments" }, { name = "pyxdg" }, { name = "requests" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/29/cd80e9108a6fc6a925ffb915f8f69198a2bb2388e39167a41d743ac2a8f4/bpython-0.26.tar.gz", hash = "sha256:f79083e1e3723be9b49c9994ad1dd3a19ccb4d0d4f9a6f5b3a73bef8bc327433", size = 207564, upload-time = "2025-10-28T07:19:41.97Z" } wheels = [ @@ -247,18 +209,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -326,22 +276,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, @@ -452,20 +386,6 @@ version = "7.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, @@ -570,7 +490,6 @@ version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ @@ -630,68 +549,17 @@ version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blessed" }, - { name = "cwcwidth", version = "0.1.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "cwcwidth", version = "0.1.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cwcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/18/5741cb42624089a815520d5b65c39c3e59673a77fd1fab6ad65bdebf2f91/curtsies-0.4.3.tar.gz", hash = "sha256:102a0ffbf952124f1be222fd6989da4ec7cce04e49f613009e5f54ad37618825", size = 53401, upload-time = "2025-06-05T06:33:20.099Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/9b/b8ee3720d056309f4ab667bfc85995c4351f67b22e8c2008612b70350c3a/curtsies-0.4.3-py3-none-any.whl", hash = "sha256:65a1b4d6ff887bd9b0f0836cc6dc68c3a2c65c57f51a62f0ee5df408edee1a99", size = 35482, upload-time = "2025-06-05T06:33:19.122Z" }, ] -[[package]] -name = "cwcwidth" -version = "0.1.11" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/bc/1746c7650e8c676e7799d40fc31d37d88882c18d92fbcdd1d014c8c8786c/cwcwidth-0.1.11.tar.gz", hash = "sha256:594d8855a6319cc3ef36e0b6374fae02e4f4fe17cd87d0debe8b6e00eb186c17", size = 71727, upload-time = "2025-10-28T08:22:08.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/ee/8073e18f9ec39195b7d44d153eea900f409324b973626c571ccc61b0b7f3/cwcwidth-0.1.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7bf37420fc4894ff21eedb069cd75f38a8f330a5c79501160a7bb21c79163ad9", size = 25105, upload-time = "2025-10-28T08:21:31.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/32/22d951c240200129e4b47849dcc6dd25f8222e5cf3812f08ad19dd1f0072/cwcwidth-0.1.11-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fd61620714344d529250a6d7b5896f51261b65526c84299691cf062cbf4666ce", size = 93135, upload-time = "2025-10-28T08:21:33.009Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d3/ef90bdf90a572dcad2eca0007752f452cd912ccec1a07b3bda3b95498d9e/cwcwidth-0.1.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea4a44ce6101a9f47491dae881cb97a31930e9d7ebd77854a2b7ee79674b3859", size = 96992, upload-time = "2025-10-28T08:21:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f8/efa7c8ee215617ca5ec8ddf469e4d7578e9aecbe819e877c830ab1a847af/cwcwidth-0.1.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d72185b2c20d85b90ef22eeec650c9e48a0652f8787811b806f2d54f1b2bbe39", size = 95306, upload-time = "2025-10-28T08:21:35.44Z" }, - { url = "https://files.pythonhosted.org/packages/e8/b2/72263ab2f036398d1babc1b4ab8a6f1c84ed2c630eb1fbf3c48e7b44d68f/cwcwidth-0.1.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e8e32df4db4a6c15770b885795f8e1bb709fc272337d4c0567691130f66ab83a", size = 94791, upload-time = "2025-10-28T08:21:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/89/e5/47790bea7f0a7abd6f50eb844caed668058819bd3edff2464b1b09755494/cwcwidth-0.1.11-cp310-cp310-win32.whl", hash = "sha256:0be3ab3e9b0b7691dce2c7099b038319cb5bc1384f53ec4c7e84371a92670db4", size = 23608, upload-time = "2025-10-28T08:21:37.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/3d/4ea6783ac7e3807204d589a167c57c5d18571c3f4723b322f139175f44e1/cwcwidth-0.1.11-cp310-cp310-win_amd64.whl", hash = "sha256:bdc00d41885d9ec4ef201e7f1c09225f895b63dde2b913bb5a62e9ce805ecf31", size = 25819, upload-time = "2025-10-28T08:21:38.458Z" }, - { url = "https://files.pythonhosted.org/packages/98/62/06a0b0ca86072e73e5a517772b0a4c7f293207b8662d9dbe33101922c611/cwcwidth-0.1.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a920e4a8734ee3da9088c9ef57ba38070c51d06131d23650fd02278b2229d72b", size = 25382, upload-time = "2025-10-28T08:21:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/4d/2e/14c02a88854c169113db2e5543e5c07903bab52a7af7db0d3060f5040d18/cwcwidth-0.1.11-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cf261cbf7cbb80f5b9382872bcab2d601c9c0ef3781933945f18d717635f67f", size = 99377, upload-time = "2025-10-28T08:21:40.136Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/1194998e82ee394dc0d489ed501b37e73a824825823cc6338cb30233e370/cwcwidth-0.1.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95cb5d3035601a2224149081d9e42e86e1740aef78ad7d82e6c7eb84e4ac6273", size = 103198, upload-time = "2025-10-28T08:21:41.041Z" }, - { url = "https://files.pythonhosted.org/packages/64/4b/a381e93922da1df7833d00068221b63ecc3f3934d360a6e1516321431385/cwcwidth-0.1.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:57a11d509afeac7f3b565948e9c760caf81d2c158d8fa8d3863b0a344871cd20", size = 101598, upload-time = "2025-10-28T08:21:42.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/4c/4c4f2066f4a014a7f00735ade8aedcfc2c9c642a24aba25f990b6ca953a6/cwcwidth-0.1.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47827c8d13b24102c3616fb920828330a732e6b3e80dc9d67f3cc2148a7b7e72", size = 101160, upload-time = "2025-10-28T08:21:43.402Z" }, - { url = "https://files.pythonhosted.org/packages/15/48/459518aa52378fd4e880030869922ab6a6a4cd1f789b8612110945bab7ec/cwcwidth-0.1.11-cp311-cp311-win32.whl", hash = "sha256:11ad90f3d75b99836aae45d509f0ae788379ef0c95c934f6d1941c59c93d9fbf", size = 23670, upload-time = "2025-10-28T08:21:44.601Z" }, - { url = "https://files.pythonhosted.org/packages/50/6c/5c2502ca2b9bfcb719e5aafb398fa6308648f207c0fb8ae64db01b0a145b/cwcwidth-0.1.11-cp311-cp311-win_amd64.whl", hash = "sha256:6c2c7d1d02b6a5d77f049a5e0ebba7917471f74c05c482e8f484194ce3c327b7", size = 25996, upload-time = "2025-10-28T08:21:45.381Z" }, - { url = "https://files.pythonhosted.org/packages/a4/14/b515b65df350fe36ab2ae446d254bc37ef08db1a3d5b9b8e1f8596232e49/cwcwidth-0.1.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31684dacd89476ebcc19e3b7317626fd9c5c04511a77d4a03df5f95f39a1daee", size = 25579, upload-time = "2025-10-28T08:21:46.497Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/5e9e8b2cd8b04669996f027e4f5d9e66e20e3ec3c6e0870c521c11b84bfe/cwcwidth-0.1.11-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0677707bd62906808e1d0a28c53eeeedaf9402d207c8f52688d2be001021c492", size = 104721, upload-time = "2025-10-28T08:21:47.756Z" }, - { url = "https://files.pythonhosted.org/packages/43/ff/0789b77c461ed903443c6239025240ec7272071a44aa4340af8a55ea2d4d/cwcwidth-0.1.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39acf51e7295e76e2397bc36f005e0fbbcca7c6863fd1d3b83f6730265e93a42", size = 107261, upload-time = "2025-10-28T08:21:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/e4/49/d09f6e459cac8479b96405e0000073d59738eeae6a477a3ed70c3dc3d25d/cwcwidth-0.1.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4367d307debcebf5253e64953675d66aa2086cf4eb579b29d72608fa99d63460", size = 104272, upload-time = "2025-10-28T08:21:49.72Z" }, - { url = "https://files.pythonhosted.org/packages/61/89/1cc2522abfe6c83adfa09b4b876541aaba14c6a09ef93eca981e9a3f7860/cwcwidth-0.1.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ac3a61d915edd746a2052554c42e3d8a4b3089622b1c0bf15dc7ecbf42594d3c", size = 105353, upload-time = "2025-10-28T08:21:50.678Z" }, - { url = "https://files.pythonhosted.org/packages/2c/86/da21524ca60ddef67a17f64dc29481bf333570d61ed6bcdec81fd2309d6a/cwcwidth-0.1.11-cp312-cp312-win32.whl", hash = "sha256:e2ee1b8345522430ddb9c5a854610f99cfe53760aa22cefb85a9ddc4fecd3640", size = 23861, upload-time = "2025-10-28T08:21:51.598Z" }, - { url = "https://files.pythonhosted.org/packages/bc/37/572682341342824076ed307ffb5f0d4ab2aca057a8586d73fe28fd483d44/cwcwidth-0.1.11-cp312-cp312-win_amd64.whl", hash = "sha256:16d26ca8da308edc0683d09e85b134b3753baad14052dd147b31c63bca379118", size = 26080, upload-time = "2025-10-28T08:21:52.358Z" }, - { url = "https://files.pythonhosted.org/packages/de/b9/7528208e30820a0b718fa4f0a094a1dd5429ecf06a2b8d673ed3977c3777/cwcwidth-0.1.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb220310460009a6f5655bb311be9cfe44762a5f7e9a6ea7fe423bd4e3763406", size = 24883, upload-time = "2025-10-28T08:21:53.157Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1a/7e372c5e5479af46b84809c1875bc981c1dafd3d91abf3284a619cdfa842/cwcwidth-0.1.11-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6002380693fd55c0ba6d8f6dca4af9f270aa9dc8f8f00041e015099e80100f8a", size = 101609, upload-time = "2025-10-28T08:21:54.056Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/868798b28e9321bc11d38905e0a840946e3a2d2c072564c7ef081df0954a/cwcwidth-0.1.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40b77971fe7471d84ebce8c393efb456435091ba6a5f9e2a381a9c169bd13a51", size = 103902, upload-time = "2025-10-28T08:21:55.878Z" }, - { url = "https://files.pythonhosted.org/packages/37/bd/5c54addc8cc8367b9edbf23820bfd11d598c6e66e929dc51ba39851c7ec4/cwcwidth-0.1.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b14b4f9d947f2ac6f1993c1ac447962d8e8c54d7479ba2d149cbc54ba45f17cb", size = 101881, upload-time = "2025-10-28T08:21:57.031Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/b285b614a36af9f50275b43a435e27041c01385e450ee13a3d5a26c5e08b/cwcwidth-0.1.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f898e6f3b07be5862185a40bd75a3eadaefa4bfd4e12f583b975cd4552b86435", size = 101613, upload-time = "2025-10-28T08:21:58.356Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/46397fba692d5ecb8dc9bddc87a2a44a4ef54cd5d2f34fc491ecfd02640f/cwcwidth-0.1.11-cp313-cp313-win32.whl", hash = "sha256:9d30cf5b19e00198dc060d989b8295ece94b67df6719045361f8c8ef93cdd60e", size = 23348, upload-time = "2025-10-28T08:21:59.239Z" }, - { url = "https://files.pythonhosted.org/packages/43/8b/f45db33a1ed0fae2d21d2ee5d992e4aec4d6a401e534fd8ede5cebe5a5c5/cwcwidth-0.1.11-cp313-cp313-win_amd64.whl", hash = "sha256:6b448e65ba72c755a258db08b6424ea58f2593fd8046240e0270d37a41f8137a", size = 25364, upload-time = "2025-10-28T08:22:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/ec3e4990f3f60461697359b6e65b67130f9303a7460c9b6c10b7d358cb68/cwcwidth-0.1.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c65d5c1799e9fa68f6a5e1b164d597cdaf9c00e31230728e1ec07e44565a2ed5", size = 25046, upload-time = "2025-10-28T08:22:00.826Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/5e9763e522df91ec9b2dc40d481b4f76f73b5721fb41f31012c295a966d8/cwcwidth-0.1.11-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5bfcf94647861bae11902b54c6114fa07e4a503252866fd1f4d00411469d71", size = 100349, upload-time = "2025-10-28T08:22:01.745Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2e/3fe196e78bbe5c722c8a7ae2f11980eaddbdbdb92df2027156b6cce5d4a4/cwcwidth-0.1.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a06f859c3716ced0572b869adc40c04c2c42326f00cda5e21237c7597f33bdda", size = 103560, upload-time = "2025-10-28T08:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/30/28/ae5b9e1743901835c9d5ed064f0aebc0af2f23421bffcc10160b0f2bdd66/cwcwidth-0.1.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cec169c878817869d1d9b32a0db42771bd5234c177582dfb2bf0632fcb6d140", size = 101375, upload-time = "2025-10-28T08:22:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/17/69/f2eb6be437b81a6a80d75f9d85c85ad38ccb5debd107ff95c8719ef29968/cwcwidth-0.1.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:01398db710dadece862038f1327bea01a28647e9f4695dbc10423e8ce125c9b9", size = 100651, upload-time = "2025-10-28T08:22:05.067Z" }, - { url = "https://files.pythonhosted.org/packages/06/74/e8b8976be02773f86cd0321da8fab9b49d9954049299af989fae7d95020b/cwcwidth-0.1.11-cp314-cp314-win32.whl", hash = "sha256:cbfa87a03a419cf672f2d942b8d4d2d7ad938709d928ad07be9d8bb4f5922034", size = 24645, upload-time = "2025-10-28T08:22:06.193Z" }, - { url = "https://files.pythonhosted.org/packages/0a/2c/b951ec7f8cbbad087265341273f861aca06509a6cc8eadedbdee24b4a5da/cwcwidth-0.1.11-cp314-cp314-win_amd64.whl", hash = "sha256:e8f301dc12e950d27ae66f0753aa01eeb222172bd463f38860c9aa669f0a5467", size = 26574, upload-time = "2025-10-28T08:22:07.019Z" }, -] - [[package]] name = "cwcwidth" version = "0.1.12" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", -] sdist = { url = "https://files.pythonhosted.org/packages/86/5f/f5c3d1b4e9c8c541406ca0654efa1bfaa05414f8e7d1c14bc6e3fd0752f8/cwcwidth-0.1.12.tar.gz", hash = "sha256:bfc16531d1246dd2558eb9b3a63aa37a9978672b956860dc5426da2343ebf366", size = 72009, upload-time = "2025-11-01T17:48:53.683Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/48/42998c088895974ee2a5ce58d3e9bec504ffb4e063dbadc9e325499220d1/cwcwidth-0.1.12-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a2c7ab3b9eb0abab9bb326fec751b36aca52e0cfe3987c0909f188b9f681042c", size = 24206, upload-time = "2025-11-01T17:48:17.749Z" }, @@ -756,18 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "furo" version = "2024.5.6" @@ -813,15 +669,6 @@ version = "3.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", size = 283510, upload-time = "2026-05-20T13:12:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", size = 599696, upload-time = "2026-05-20T14:00:02.906Z" }, - { url = "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", size = 612618, upload-time = "2026-05-20T14:05:39.202Z" }, - { url = "https://files.pythonhosted.org/packages/2e/19/60df45065b2981ff894fdd51e7c99a3a4b107412822b083d88d5d528f663/greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", size = 619237, upload-time = "2026-05-20T14:09:06.421Z" }, - { url = "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", size = 612947, upload-time = "2026-05-20T13:14:23.469Z" }, - { url = "https://files.pythonhosted.org/packages/26/9a/4ba4c2bc9d9df5f41bb8943fb7bb11e440352e6b9c2e36716b6e85f8b82d/greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", size = 415653, upload-time = "2026-05-20T14:01:36.999Z" }, - { url = "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", size = 1571425, upload-time = "2026-05-20T14:02:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", size = 1638688, upload-time = "2026-05-20T13:14:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", size = 237763, upload-time = "2026-05-20T13:11:35.659Z" }, { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, @@ -962,18 +809,6 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, - { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, @@ -1059,17 +894,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -1142,10 +966,6 @@ wheels = [ name = "marshmallow" version = "4.3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485, upload-time = "2026-04-03T21:46:32.72Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148, upload-time = "2026-04-03T21:46:31.241Z" }, @@ -1173,29 +993,8 @@ wheels = [ name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, @@ -1316,18 +1115,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, - { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, - { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, - { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, - { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, @@ -1429,23 +1220,6 @@ version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, - { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, - { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, - { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, - { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, - { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, - { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, - { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, - { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, - { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, - { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, @@ -1599,20 +1373,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, @@ -1746,6 +1506,7 @@ wheels = [ name = "pytenable" source = { editable = "." } dependencies = [ + { name = "arrow" }, { name = "defusedxml" }, { name = "gql" }, { name = "graphql-core" }, @@ -1756,7 +1517,6 @@ dependencies = [ { name = "python-dateutil" }, { name = "requests" }, { name = "requests-toolbelt" }, - { name = "restfly" }, { name = "semver" }, { name = "typing-extensions" }, { name = "urllib3" }, @@ -1783,6 +1543,7 @@ dev = [ { name = "responses" }, { name = "rich" }, { name = "ruff" }, + { name = "rust-just" }, { name = "typer" }, { name = "urllib3" }, ] @@ -1794,6 +1555,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "arrow", specifier = ">=1.4.0" }, { name = "cryptography", marker = "extra == 'pkcs12'", specifier = ">=43.0.1" }, { name = "defusedxml", specifier = ">=0.5.0" }, { name = "gql", specifier = ">=4.0.0" }, @@ -1804,12 +1566,11 @@ requires-dist = [ { name = "pytenable", extras = ["pkcs12"], marker = "extra == 'all'" }, { name = "python-box", specifier = ">=4.0" }, { name = "python-dateutil", specifier = ">=2.6" }, - { name = "requests", specifier = ">=2.26" }, + { name = "requests", specifier = ">=2.33" }, { name = "requests-toolbelt", specifier = ">=1.0.0" }, - { name = "restfly", specifier = ">=1.5.1" }, { name = "semver", specifier = ">=2.10.0" }, { name = "typing-extensions", specifier = ">=4.0.1" }, - { name = "urllib3", specifier = ">=1.26.18" }, + { name = "urllib3", specifier = ">=1.26.20" }, ] provides-extras = ["pkcs12", "all"] @@ -1819,15 +1580,16 @@ dev = [ { name = "mock", specifier = ">=5.1.0" }, { name = "mypy", specifier = ">=1.18.2" }, { name = "ptpython", specifier = ">=3.0.29" }, - { name = "pytest", specifier = ">=7.4.4,<9" }, + { name = "pytest", specifier = ">=8,<9" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "pytest-datafiles", specifier = ">=3.0.0" }, { name = "pytest-vcr", specifier = ">=1.0.2" }, { name = "responses", specifier = ">=0.23.3" }, { name = "rich", specifier = ">=13.8.1" }, { name = "ruff", specifier = ">=0.6.4" }, + { name = "rust-just", specifier = ">=1.51.0" }, { name = "typer", specifier = ">=0.20.0" }, - { name = "urllib3", specifier = "==1.26.20" }, + { name = "urllib3", specifier = ">=1.26.18,<2" }, ] docs = [ { name = "autodoc-pydantic", specifier = ">=2.2.0" }, @@ -1841,12 +1603,10 @@ version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1898,9 +1658,6 @@ version = "7.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0f/0f/34e7ee0a72f1464b4c7a2e8bafb389f230477256af586bc82bcfad85295a/python_box-7.4.1.tar.gz", hash = "sha256:e412e36c25fca8223560516d53ef6c7993591c3b0ec8bb4ec582bf7defdd79f0", size = 49859, upload-time = "2026-02-21T16:21:16.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/7f/0ff288dedf965f504de7a8d9d7353f7b7e57bf18ce9beb328bad49dff026/python_box-7.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e724eb25bfda0f1dbbe79c8a35ce8877d8ad7afbdd9396757c6f509f0e742f8c", size = 1877032, upload-time = "2026-02-21T16:21:59.509Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d0/e211693f3ac4f11b553f214fcf2a2687be42052ad6905832258d451003b7/python_box-7.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee7bb8b0c4d1a07f12454a1edc1a936c4bf952adead3eb40c38aee600a2b605b", size = 4307977, upload-time = "2026-02-21T16:26:01.465Z" }, - { url = "https://files.pythonhosted.org/packages/47/94/94690a217ecb1333a8d796698176456bc03cdf707d903a6bd1aab022a497/python_box-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:526dcc3d82a6957b177313e8704ede431b9add0209b76d716eb232c9a5d283e5", size = 1317804, upload-time = "2026-02-21T16:21:58.2Z" }, { url = "https://files.pythonhosted.org/packages/f8/a8/c8bcd3ff0905ec549273ea3485e6b9f2039f57baab419123fb18f964f829/python_box-7.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3f76dad8be9d57d65a3edc792b952f7afe3991515aa6eba616cf5efb2fbb2e0c", size = 1870869, upload-time = "2026-02-21T16:21:34.16Z" }, { url = "https://files.pythonhosted.org/packages/0f/bc/9382766d388e258363a18a094e251d2624e3c524614c733d1afa989d9770/python_box-7.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c66582f41a94d46cb0896d468b0efebf9bc4c3a5634cd15373d871767c2e741d", size = 4494287, upload-time = "2026-02-21T16:26:03.131Z" }, { url = "https://files.pythonhosted.org/packages/8c/cf/b9d1d4550615f69f6f9c72767f026543442739e5c56adf6f844b50d88251/python_box-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:43c62f66d694eb6410f51eb2eb5726f9b466e6f685e5dc90b5cd11f7b3047362", size = 1321085, upload-time = "2026-02-21T16:22:01.093Z" }, @@ -1951,15 +1708,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -2050,21 +1798,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, ] -[[package]] -name = "restfly" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arrow" }, - { name = "python-box" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/56/49209c8f47ff19f2f37f10f2d02897458b28e0c8e5cc82c0d53f6cf7e065/restfly-1.5.1.tar.gz", hash = "sha256:9468c7c689c0b22e419dd328fe544a7b72b514982ea22ba5f1f56b14bd845cf6", size = 29674, upload-time = "2025-01-17T21:02:02.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/f2/d7b810726fc323c0fabcb2d5be40100fb38f7736930366d213df3d1d81da/restfly-1.5.1-py3-none-any.whl", hash = "sha256:52e07e7b5cfe2331e7f838bb1a45f465b52faf9fabca944b850ba92cda9e532f", size = 25359, upload-time = "2025-01-17T21:01:59.767Z" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -2103,6 +1836,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] +[[package]] +name = "rust-just" +version = "1.51.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/97/b3fcff8f582fa7941bb0a1675f0230f84134423dd6948f589c1fce176b08/rust_just-1.51.0.tar.gz", hash = "sha256:b05f9c3d1bf32b4a2297514c1e03a82377cf51cd8fbb39a7c40f6be2b3bbbf31", size = 1918349, upload-time = "2026-05-11T04:14:13.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/10/4bb1a0865ff38f45c2032f76bd0686fb05719b7dc8f520990de1460df5d4/rust_just-1.51.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d6fd9233cce9550cbbae817648919f1f8bc5e7b44b36be3659d044036691690", size = 1987826, upload-time = "2026-05-11T04:13:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/d2dd697b265487a7fa7b90416231b310100b32f87052c1f96a45fa8e9ff4/rust_just-1.51.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0c8eed2316da1663db7fcb293986eb46fa70c5575f3cf3388535f1c4ac65b1b", size = 1854900, upload-time = "2026-05-11T04:04:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/2910efca846f35d2960ad816e3cbade987858b4d46158cbdcc8ef99de155/rust_just-1.51.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ce6192f5160c78e1add2d5f496e04f38e30762f66012bb193ffe09c68f38dcb", size = 1936582, upload-time = "2026-05-11T04:13:54.383Z" }, + { url = "https://files.pythonhosted.org/packages/c2/07/60b0b47d3f1287d798164404737029c3a391066787bfd2786f58aef0281e/rust_just-1.51.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:28cb32c9304873c1b55c9c7aab110c3198458bfd92e98014e8973184635c9877", size = 1919519, upload-time = "2026-05-11T04:13:56.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8bfba323b1de9ee19f647252c8170f2db732c1ca3df4cb26df0e25955d76/rust_just-1.51.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f0e9396f8f83cedd4d8e2f7532f23aaf674f57ca7803084c28d18212216546f", size = 2104936, upload-time = "2026-05-11T04:13:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/cf6180bf7a82fc6d4b75d8d10a34454cc4df7d32f9dd46a0747a5c734237/rust_just-1.51.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddb7c64fe140f47ace4e3a81064f21b2bfb3c774081ee56b91453b867df0565f", size = 2178074, upload-time = "2026-05-11T04:04:09.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/260579bbb3f7e72c15179f99d62e5dc88a7f64f766e9e03a6646ebed77a3/rust_just-1.51.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ee211392f5f080c3a1e07b85f58787e35c8a975f52e15a24ffb94e1fc1b08fc", size = 2121560, upload-time = "2026-05-11T04:04:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/9c/96/ae3eca10e3ab476a372279df61c09c9968b540ea2adbffe74078d939710d/rust_just-1.51.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71fdbd29ad68d67d0ee885ed0eb74f0e906f3e89601b860f69d88bdd4454a869", size = 2093583, upload-time = "2026-05-11T04:13:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/8d/42/8a5c573c5f154172e5835de0f230e7d1db959ee5f742697247467b9a07a7/rust_just-1.51.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6084d6122f36552626c913a55d658c80975a32dbe51b0d27ed0a48cd037c0c25", size = 1952338, upload-time = "2026-05-11T04:14:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/dfb7a8be144ff16e74091879c55bc38d4c9102db1e831cab5379762b4691/rust_just-1.51.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262a39565ad782d2621ea31de02acda02ef9640074adfc6e2bfe4f12b48b51ae", size = 1960672, upload-time = "2026-05-11T04:04:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/4d2b03d96accf8a1ff46d7c2102e96ae797d3efa765d70cdbda51dbc4d1e/rust_just-1.51.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e3825497e14bc17d9f1944f45a73f7a996b2d090474477d0d0c16095b7e07e31", size = 1949086, upload-time = "2026-05-11T04:14:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/f41378dc317abdb6560f13f7b3ea2d7227d39b2c8272c75f28ba77086d4c/rust_just-1.51.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7015698dcdaac6f1d64610f684a29d42c17431e5802de429d8400a562afe953", size = 2080788, upload-time = "2026-05-11T04:14:05.158Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/dc67c2d85347c02c1d3c523a89ace6d1a03e621571bb8c02987d6e443e65/rust_just-1.51.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:9dc7543b899d97f8b6c20946fc88d12f2c8593aefc706b1e6e56314de87e3d41", size = 2122670, upload-time = "2026-05-11T04:14:06.596Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/47faef6b1271cba107e4a70d486516b00e3eb2f1db005dc177bb7edc8aa9/rust_just-1.51.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2fe6e241f4a192c2c7fbb9ee592792422c561c328d0890671c966bac18eee27", size = 2165842, upload-time = "2026-05-11T04:14:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/99/e9/78ec8d185efd12449572d806cc5bbc87815d186e5e387198d07941070904/rust_just-1.51.0-py3-none-win32.whl", hash = "sha256:3806a2611fc67d2255a1ef796b45efb0a511b360011d700c3eac8ef5be8173c6", size = 1858593, upload-time = "2026-05-11T04:14:09.875Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/01dddc2d969613ab23e9edd05bd90e6354591c9b3b9c40f2cf56e2c2af23/rust_just-1.51.0-py3-none-win_amd64.whl", hash = "sha256:e85ce73a2e38cd7b4da9f8389bcd547e8f7a11261671acf1dda13f09754c3bf0", size = 2063796, upload-time = "2026-05-11T04:14:11.317Z" }, +] + [[package]] name = "semver" version = "3.0.4" @@ -2169,7 +1926,6 @@ dependencies = [ { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ @@ -2391,17 +2147,6 @@ version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, - { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, - { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, - { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, - { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, @@ -2482,23 +2227,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, - { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, - { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, - { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, - { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, - { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, - { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, - { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, - { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, - { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" },