diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac2de84..95e7159 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,20 @@ on: - master jobs: + lint: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Lint + run: make lint CHECK=1 + tests: runs-on: ubuntu-24.04 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8d37cf3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format \ No newline at end of file diff --git a/Makefile b/Makefile index 0d42487..10e70d6 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,14 @@ install-test: install ## Install test dependencies in local virtualenv install-lint: install ## Install lint dependencies in local virtualenv ($(VENV_RUN); $(PIP_CMD) install -r $(LINT_REQS)) -lint: install-lint ## Format code with ruff - $(VENV_DIR)/bin/ruff format postgresql_proxy tests plugins +install-pre-commit: install-lint ## Install and register the pre-commit hook + $(VENV_DIR)/bin/pre-commit install + +CHECK ?= + +lint: install-lint ## Format code with ruff (use CHECK=1 to check without modifying) + $(VENV_DIR)/bin/ruff format $(if $(CHECK),--check,) postgresql_proxy tests plugins + $(VENV_DIR)/bin/ruff check $(if $(CHECK),,--fix) postgresql_proxy tests plugins start-postgres: ## Start local PostgreSQL test container and wait until ready @set -euo pipefail; \ diff --git a/plugins/tableau_hll/__init__.py b/plugins/tableau_hll/__init__.py index 0790713..7b2ebcd 100644 --- a/plugins/tableau_hll/__init__.py +++ b/plugins/tableau_hll/__init__.py @@ -2,13 +2,19 @@ import re # The field to replace -field_pattern = re.compile('(?<=[^\w])count\(distinct (?:cast\()?("[^"]+")\.("[^"]+")(?: as text\))?\)', re.IGNORECASE) +field_pattern = re.compile( + '(?<=[^\w])count\(distinct (?:cast\()?("[^"]+")\.("[^"]+")(?: as text\))?\)', + re.IGNORECASE, +) # Table name -table_pattern = re.compile('from ([^\(\)]+?)\s*\)? (?:AS )?("[^"]+")', re.IGNORECASE | re.DOTALL) +table_pattern = re.compile( + 'from ([^\(\)]+?)\s*\)? (?:AS )?("[^"]+")', re.IGNORECASE | re.DOTALL +) + def rewrite_query(query, context): - original_table = '' - table_alias = '' + original_table = "" + table_alias = "" # cache only works on current query. Mainly because there's no way to tell if the table has been modified between # 2 different requests. @@ -26,8 +32,8 @@ def replace(match): hll_column_candidate = match.group(2).strip() # need to know which columns are hll - if not hll_table.lower() in column_cache: - db_conn_info = context['instance_config'].redirect + if hll_table.lower() not in column_cache: + db_conn_info = context["instance_config"].redirect conn = None try: conn = psycopg2.connect( @@ -36,17 +42,17 @@ def replace(match): db_conn_info.host, db_conn_info.port, # Get auth information from the proxied request - context['connect_params']['database'], - context['connect_params']['user'] + context["connect_params"]["database"], + context["connect_params"]["user"], ) ) - + hll_type_code = None cur = conn.cursor() try: cur.execute("SELECT oid FROM pg_type WHERE typname='hll';") - hll_type_code, = cur.fetchone() - except: + (hll_type_code,) = cur.fetchone() + except Exception: pass finally: cur.close() @@ -65,7 +71,7 @@ def replace(match): hll_columns.add(desc.name.lower()) column_cache[hll_table.lower()] = hll_columns - except: + except Exception: pass finally: cur.close() @@ -76,13 +82,17 @@ def replace(match): conn.close() # Replace - if hll_column_candidate.strip('"').lower() in column_cache[hll_table.lower()]: - return ' hll_cardinality(hll_union_agg({}.{})) :: BIGINT '.format(match.group(1), match.group(2)) + if ( + hll_column_candidate.strip('"').lower() + in column_cache[hll_table.lower()] + ): + return " hll_cardinality(hll_union_agg({}.{})) :: BIGINT ".format( + match.group(1), match.group(2) + ) # Don't replace return match.group(0) - # Matches this string. The 2 groups are `schema.table` and `"alias"` # FROM schema.table) "alias" table_result = table_pattern.search(query) diff --git a/postgresql_proxy/config_schema.py b/postgresql_proxy/config_schema.py index ed9761b..4098fa8 100644 --- a/postgresql_proxy/config_schema.py +++ b/postgresql_proxy/config_schema.py @@ -1,17 +1,18 @@ +"""This class is used to validate the config""" + import logging -''' This class is used to validate the config -''' + class Schema: def _validate(self): pass def __hyphen_to_underscore(self, k): - return k.replace('-', '_') + return k.replace("-", "_") def _populate(self, data, definition): try: - for (k, v) in data.items(): + for k, v in data.items(): k = self.__hyphen_to_underscore(k) if k in definition: vtype = definition[k] @@ -30,11 +31,15 @@ def _populate(self, data, definition): def _assert_non_empty(self, *attrs): for attr in attrs: - assert len(getattr(self, attr)) > 0, "{}.{} must not be empty".format(type(self).__name__, attr) + assert len(getattr(self, attr)) > 0, "{}.{} must not be empty".format( + type(self).__name__, attr + ) def _assert_non_null(self, *attrs): for attr in attrs: - assert getattr(self, attr) is not None, "{}.{} must not be None".format(type(self).__name__, attr) + assert getattr(self, attr) is not None, "{}.{} must not be None".format( + type(self).__name__, attr + ) class InterceptQuerySettings(Schema): @@ -42,14 +47,11 @@ def __init__(self, data): self.plugin = None self.function = None - self._populate(data, { - 'plugin': str, - 'function': str - }) + self._populate(data, {"plugin": str, "function": str}) def _validate(self): - self._assert_non_null('plugin', 'function') - self._assert_non_empty('plugin', 'function') + self._assert_non_null("plugin", "function") + self._assert_non_empty("plugin", "function") class InterceptCommandSettings(Schema): @@ -57,10 +59,7 @@ def __init__(self, data): self.queries = [] self.connects = None - self._populate(data, { - 'queries': [InterceptQuerySettings], - 'connects': str - }) + self._populate(data, {"queries": [InterceptQuerySettings], "connects": str}) class InterceptResponseSettings(Schema): @@ -68,10 +67,9 @@ def __init__(self, data): self.parameter_responses = [] self.connects = None - self._populate(data, { - 'parameter_status': [InterceptQuerySettings], - 'connects': str - }) + self._populate( + data, {"parameter_status": [InterceptQuerySettings], "connects": str} + ) class InterceptSettings(Schema): @@ -79,10 +77,13 @@ def __init__(self, data): self.commands = None self.responses = None - self._populate(data, { - 'commands': InterceptCommandSettings, - 'responses': InterceptResponseSettings, - }) + self._populate( + data, + { + "commands": InterceptCommandSettings, + "responses": InterceptResponseSettings, + }, + ) class Connection(Schema): @@ -91,15 +92,11 @@ def __init__(self, data): self.host = None self.port = None - self._populate(data, { - 'name': str, - 'host': str, - 'port': int - }) + self._populate(data, {"name": str, "host": str, "port": int}) def _validate(self): - self._assert_non_null('name', 'host', 'port') - self._assert_non_empty('name') + self._assert_non_null("name", "host", "port") + self._assert_non_empty("name") class InstanceSettings(Schema): @@ -108,15 +105,17 @@ def __init__(self, data): self.redirect = None self.intercept = None - self._populate(data, { - 'listen': Connection, - 'redirect': Connection, - 'intercept': InterceptSettings - }) - + self._populate( + data, + { + "listen": Connection, + "redirect": Connection, + "intercept": InterceptSettings, + }, + ) def _validate(self): - self._assert_non_null('listen', 'redirect') + self._assert_non_null("listen", "redirect") class Settings(Schema): @@ -125,15 +124,13 @@ def __init__(self, data): self.intercept_log = None self.general_log = None - self._populate(data, { - 'log_level': str, - 'intercept_log': str, - 'general_log': str - }) + self._populate( + data, {"log_level": str, "intercept_log": str, "general_log": str} + ) def _validate(self): - self._assert_non_null('log_level', 'intercept_log', 'general_log') - self._assert_non_empty('log_level', 'intercept_log', 'general_log') + self._assert_non_null("log_level", "intercept_log", "general_log") + self._assert_non_empty("log_level", "intercept_log", "general_log") class Config(Schema): @@ -142,11 +139,10 @@ def __init__(self, data): self.settings = None self.instances = [] - self._populate(data, { - 'plugins' : [str], - 'settings' : Settings, - 'instances' : [InstanceSettings] - }) + self._populate( + data, + {"plugins": [str], "settings": Settings, "instances": [InstanceSettings]}, + ) def _validate(self): - self._assert_non_empty('instances') + self._assert_non_empty("instances") diff --git a/postgresql_proxy/connection.py b/postgresql_proxy/connection.py index 7b03eb9..9d1c3d4 100644 --- a/postgresql_proxy/connection.py +++ b/postgresql_proxy/connection.py @@ -13,15 +13,15 @@ def __init__(self, sock, address, name, events, context): self.context = context self.interceptor = None self.redirect_conn: Optional[Connection] = None - self.out_bytes = b'' - self.in_bytes = b'' + self.out_bytes = b"" + self.in_bytes = b"" self.terminated = False def parse_length(self, length_bytes): - return int.from_bytes(length_bytes, 'big') + return int.from_bytes(length_bytes, "big") def encode_length(self, length): - return length.to_bytes(4, byteorder='big') + return length.to_bytes(4, byteorder="big") def received(self, in_bytes): self.in_bytes += in_bytes @@ -29,12 +29,12 @@ def received(self, in_bytes): # Otherwise wait for more bytes to be received (break and exit) while True: ptype = self.in_bytes[0:1] - if ptype == b'\x00': + if ptype == b"\x00": if len(self.in_bytes) < 4: break header_length = 4 body_length = self.parse_length(self.in_bytes[0:4]) - 4 - elif ptype == b'N': + elif ptype == b"N": header_length = 1 body_length = 0 else: @@ -52,18 +52,22 @@ def received(self, in_bytes): self.in_bytes = self.in_bytes[length:] def process_inbound_packet(self, header, body): - if header != b'N': + if header != b"N": packet_type = header[0:-4] - _logger.info("intercepting packet of type '%s' from %s", packet_type, self.name) + _logger.info( + "intercepting packet of type '%s' from %s", packet_type, self.name + ) body = self.interceptor.intercept(packet_type, body) header = packet_type + self.encode_length(len(body) + 4) - if packet_type == b'X': + if packet_type == b"X": # this a termination packet, it will indicate that the proxied client wants to close the # postgres connection properly self.terminated = True message = header + body - _logger.debug("Received message. Relaying. Speaker: %s, message:\n%s", self.name, message) + _logger.debug( + "Received message. Relaying. Speaker: %s, message:\n%s", self.name, message + ) if self.redirect_conn: # redirect_conn might not be set (anymore) at this stage diff --git a/postgresql_proxy/constants.py b/postgresql_proxy/constants.py index 006094f..07fc59b 100644 --- a/postgresql_proxy/constants.py +++ b/postgresql_proxy/constants.py @@ -1,4 +1,3 @@ - ALLOWED_CONNECTION_PARAMETERS = [ "host", "hostaddr", diff --git a/postgresql_proxy/interceptors.py b/postgresql_proxy/interceptors.py index 56476a4..60dc937 100644 --- a/postgresql_proxy/interceptors.py +++ b/postgresql_proxy/interceptors.py @@ -19,18 +19,24 @@ def _get_plugin_interceptor_function(self, interceptor): return func else: - raise Exception("Can't find function {} in plugin {}".format( - interceptor.function, - interceptor.plugin - )) + raise Exception( + "Can't find function {} in plugin {}".format( + interceptor.function, interceptor.plugin + ) + ) else: raise Exception("Plugin {} not loaded".format(interceptor.plugin)) def get_codec(self): - if self.context is not None and 'connect_params' in self.context: - if self.context['connect_params'] is not None and 'client_encoding' in self.context['connect_params']: - return self.convert_encoding_to_python(self.context['connect_params']['client_encoding']) - return 'utf-8' + if self.context is not None and "connect_params" in self.context: + if ( + self.context["connect_params"] is not None + and "client_encoding" in self.context["connect_params"] + ): + return self.convert_encoding_to_python( + self.context["connect_params"]["client_encoding"] + ) + return "utf-8" @staticmethod def convert_encoding_to_python(encoding: str) -> str: @@ -45,10 +51,10 @@ class CommandInterceptor(Interceptor): def intercept(self, packet_type, data): if self.interceptor_config.queries is not None: ic_queries = self.interceptor_config.queries - if packet_type == b'Q': + if packet_type == b"Q": # Query, ends with b'\x00' data = self._intercept_query(data, ic_queries) - elif packet_type == b'P': + elif packet_type == b"P": # Statement that needs parsing. # First byte of the body is some Statement flag. Ignore, don't lose # Next is the query, same as above, ends with an b'\x00' @@ -58,7 +64,7 @@ def intercept(self, packet_type, data): params = data[-2:] data = statement + query + params - if packet_type == b'': + if packet_type == b"": # Connection request / context. Ignore the first 4 bytes, keep it packet_start = data[0:4] context_data = self._intercept_context_data(data[4:-1]) @@ -68,49 +74,52 @@ def intercept(self, packet_type, data): def _intercept_context_data(self, data): # Each entry is terminated by b'\x00' - entries = data.split(b'\x00')[:-1] + entries = data.split(b"\x00")[:-1] entries = dict(zip(entries[0::2], entries[1::2])) - self.context['connect_params'] = {} + self.context["connect_params"] = {} # Try to set codec, then transcode the dict - if b'client_encoding' in entries: - self.context['connect_params']['client_encoding'] = entries[b'client_encoding'].decode('ascii') + if b"client_encoding" in entries: + self.context["connect_params"]["client_encoding"] = entries[ + b"client_encoding" + ].decode("ascii") codec = self.get_codec() for k, v in entries.items(): key: str = k.decode(codec) # don't keep parameters not allowed by postgres if key.lower() not in ALLOWED_CONNECTION_PARAMETERS: continue - self.context['connect_params'][k.decode(codec)] = v.decode(codec) + self.context["connect_params"][k.decode(codec)] = v.decode(codec) - context_data = b'\x00'.join( + context_data = b"\x00".join( [ - key.encode(codec) + b'\x00' + value.encode(codec) - for key, value in self.context['connect_params'].items() + key.encode(codec) + b"\x00" + value.encode(codec) + for key, value in self.context["connect_params"].items() ] ) - return context_data + b'\x00\x00' + return context_data + b"\x00\x00" def _intercept_query(self, query, interceptors): - logging.getLogger('intercept').debug("intercepting query\n%s", query) + logging.getLogger("intercept").debug("intercepting query\n%s", query) # Remove zero byte at the end - query = query[:-1].decode('utf-8') + query = query[:-1].decode("utf-8") for interceptor in interceptors: func = self._get_plugin_interceptor_function(interceptor) query = func(query, self.context) - logging.getLogger('intercept').debug( + logging.getLogger("intercept").debug( "modifying query using interceptor %s.%s\n%s", interceptor.plugin, interceptor.function, - query) + query, + ) # Append the zero byte at the end - return query.encode('utf-8') + b'\x00' + return query.encode("utf-8") + b"\x00" class ResponseInterceptor(Interceptor): def intercept(self, packet_type, data): if (ic_param_status := self.interceptor_config.parameter_status) is not None: - if packet_type == b'S': + if packet_type == b"S": # ParameterStatus, see https://www.postgresql.org/docs/current/protocol-message-formats.html#PROTOCOL-MESSAGE-FORMATS-PARAMETERSTATUS data = self._intercept_parameter_status(data, ic_param_status) @@ -123,7 +132,7 @@ def _intercept_parameter_status(self, data, interceptors): for interceptor in interceptors: func = self._get_plugin_interceptor_function(interceptor) key, value = func(key, value, self.context) - logging.getLogger('intercept').debug( + logging.getLogger("intercept").debug( "modifying parameter status using interceptor %s.%s\nkey=%s value=%s", interceptor.plugin, interceptor.function, diff --git a/postgresql_proxy/proxy.py b/postgresql_proxy/proxy.py index ba8b31e..02b0388 100644 --- a/postgresql_proxy/proxy.py +++ b/postgresql_proxy/proxy.py @@ -1,4 +1,4 @@ -'''For every configured instance, a Proxy object is created, that starts a listener. +"""For every configured instance, a Proxy object is created, that starts a listener. On connect, it initiates a parallel connection to postgresql and pairs them together. Using selectors, packets are received, intercepted and relayed to the other party. @@ -22,7 +22,7 @@ proxy.py - connections and sockets things connection.py - parsing and composing packets, launching interceptors interceptors.py - intercepting for modification -''' +""" from __future__ import annotations @@ -75,17 +75,21 @@ def _create_pg_connection(self, address, context): pg_sock.setblocking(False) events = selectors.EVENT_READ - redirect_config_name = redirect_config.name + '_' + str(self.num_clients) + redirect_config_name = redirect_config.name + "_" + str(self.num_clients) pg_conn = connection.Connection( pg_sock, name=redirect_config_name, address=address, events=events, - context=context + context=context, ) - LOG.info("initiated client connection to %s:%s called %s", - redirect_config.host, redirect_config.port, redirect_config_name) + LOG.info( + "initiated client connection to %s:%s called %s", + redirect_config.host, + redirect_config.port, + redirect_config_name, + ) return pg_conn def _register_conn(self, conn: connection.Connection): @@ -109,12 +113,15 @@ def _unregister_conn(self, conn: connection.Connection): # this will cause postgres to close the socket on its side cleanly try: LOG.debug("try closing connection %s", conn.redirect_conn.name) - conn.redirect_conn.sock.send(b'X\x00\x00\x00\x04') + conn.redirect_conn.sock.send(b"X\x00\x00\x00\x04") # remove reference to itself conn.redirect_conn.redirect_conn = None except OSError: # OSError includes all socket exceptions + Connection* related exceptions - LOG.debug("tried closing connection %s: already closed", conn.redirect_conn.name) + LOG.debug( + "tried closing connection %s: already closed", + conn.redirect_conn.name, + ) if self._debug: self._registered_conn.discard(f"{conn.name}-{conn.sock.fileno()}") @@ -231,11 +238,11 @@ def service_connection(self, key: SelectorKeyProxy, mask): sock = key.fileobj conn = key.data if mask & selectors.EVENT_READ: - LOG.debug('%s can receive', conn.name) + LOG.debug("%s can receive", conn.name) try: recv_data = sock.recv(4096) # Should be ready to read if recv_data: - LOG.debug('%s received data:\n%s', conn.name, recv_data) + LOG.debug("%s received data:\n%s", conn.name, recv_data) conn.received(recv_data) # excerpt from https://docs.python.org/3/library/ssl.html#ssl-nonblocking # Conversely, since the SSL layer has its own framing, a SSL socket may still have data available @@ -244,30 +251,34 @@ def service_connection(self, key: SelectorKeyProxy, mask): while isinstance(sock, ssl.SSLSocket) and sock.pending() > 0: extra = sock.recv(4096) if extra: - LOG.debug('%s received pending SSL data:\n%s', conn.name, extra) + LOG.debug( + "%s received pending SSL data:\n%s", conn.name, extra + ) conn.received(extra) else: self._unregister_conn(conn) - LOG.debug('%s connection closing %s', conn.name, conn.address) + LOG.debug("%s connection closing %s", conn.name, conn.address) # A file object shall be unregistered prior to being closed. sock.close() except OSError as e: # it means the socket was closed by peer - LOG.debug('%s connection closed by peer %s: %s', conn.name, conn.address, e) + LOG.debug( + "%s connection closed by peer %s: %s", conn.name, conn.address, e + ) self._unregister_conn(conn) next_conn = conn.redirect_conn if next_conn and next_conn.out_bytes: try: while next_conn.out_bytes: - LOG.debug('sending to %s:\n%s', next_conn.name, next_conn.out_bytes) + LOG.debug("sending to %s:\n%s", next_conn.name, next_conn.out_bytes) sent = next_conn.sock.send(next_conn.out_bytes) next_conn.sent(sent) except OSError: # If one side is closed, close the other one # this can happen in the case where the client disconnects, and postgres still return a response # we then read the response then close the PG side of the socket. - LOG.debug('error sending to %s: connection closed', next_conn.name) + LOG.debug("error sending to %s: connection closed", next_conn.name) self._unregister_conn(conn) sock.close() @@ -301,7 +312,10 @@ def listen(self, max_connections: int = 8): self.service_connection(key, mask) except OSError as ex: - LOG.error("Can't establish PostgreSQL proxy listener on port %s" % port, exc_info=ex) + LOG.error( + "Can't establish PostgreSQL proxy listener on port %s" % port, + exc_info=ex, + ) except Exception: LOG.exception("PostgreSQL proxy quit unexpectedly:") finally: @@ -311,7 +325,9 @@ def listen(self, max_connections: int = 8): # it should not happen anymore if self._debug: LOG.debug("Registered connections dangling: %s", self._registered_conn) - registered_selector_sockets = [skey for i, skey in self.selector.get_map().items()] + registered_selector_sockets = [ + skey for i, skey in self.selector.get_map().items() + ] for selector_key in registered_selector_sockets: LOG.debug("Connection left: %s", selector_key) selector_key: SelectorKeyProxy @@ -337,7 +353,7 @@ def main(): path = os.path.dirname(os.path.realpath(__file__)) config = None try: - with open(path + '/' + 'config.yml', 'r') as fp: + with open(path + "/" + "config.yml", "r") as fp: config = cfg.Config(yaml.load(fp)) except Exception: logging.critical("Could not read config. Aborting.") @@ -346,24 +362,28 @@ def main(): logging.basicConfig( filename=config.settings.general_log, level=getattr(logging, config.settings.log_level.upper()), - format='%(asctime)s : %(levelname)s : %(message)s' + format="%(asctime)s : %(levelname)s : %(message)s", ) - qlog = logging.getLogger('intercept') - qformat = logging.Formatter('%(asctime)s : %(message)s') - qhandler = logging.FileHandler(config.settings.intercept_log, mode = 'w') + qlog = logging.getLogger("intercept") + qformat = logging.Formatter("%(asctime)s : %(message)s") + qhandler = logging.FileHandler(config.settings.intercept_log, mode="w") qhandler.setFormatter(qformat) qlog.addHandler(qhandler) qlog.setLevel(logging.DEBUG) - print('general log, level {}: {}'.format(config.settings.log_level, config.settings.general_log)) - print('intercept log: {}'.format(config.settings.intercept_log)) - print('further messages directed to log') + print( + "general log, level {}: {}".format( + config.settings.log_level, config.settings.general_log + ) + ) + print("intercept log: {}".format(config.settings.intercept_log)) + print("further messages directed to log") plugins = {} for plugin in config.plugins: logging.info("Loading module %s", plugin) - module = importlib.import_module('plugins.' + plugin) + module = importlib.import_module("plugins." + plugin) plugins[plugin] = module for instance in config.instances: diff --git a/requirements-lint.txt b/requirements-lint.txt index ace94c5..7d36568 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1,2 @@ ruff==0.15.12 +pre-commit diff --git a/setup.py b/setup.py index bbf4c72..b25215c 100644 --- a/setup.py +++ b/setup.py @@ -3,19 +3,18 @@ install_requires = [] -if __name__ == '__main__': - +if __name__ == "__main__": setup( - name='postgresql-proxy', - version='0.3.1', - description='Postgresql Proxy', - packages=find_packages(exclude=('tests', 'tests.*')), + name="postgresql-proxy", + version="0.3.1", + description="Postgresql Proxy", + packages=find_packages(exclude=("tests", "tests.*")), install_requires=install_requires, zip_safe=False, classifiers=[ - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.13', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Software Development :: Testing', - ] + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: Apache Software License", + "Topic :: Software Development :: Testing", + ], )