├── docker └── trino │ ├── etc │ ├── node.properties │ ├── config.properties │ └── jvm.config │ └── catalog │ ├── memory.properties │ └── iceberg.properties ├── pyproject.toml ├── tests ├── conftest.py └── test_maintenance.py ├── README.md ├── docker-compose-trino.yml ├── trino_iceberg_maintenance └── __main__.py └── poetry.lock /docker/trino/etc/node.properties: -------------------------------------------------------------------------------- 1 | node.environment=docker 2 | node.data-dir=/data/trino 3 | -------------------------------------------------------------------------------- /docker/trino/catalog/memory.properties: -------------------------------------------------------------------------------- 1 | connector.name=memory 2 | memory.max-data-per-node=128MB 3 | -------------------------------------------------------------------------------- /docker/trino/etc/config.properties: -------------------------------------------------------------------------------- 1 | coordinator=true 2 | node-scheduler.include-coordinator=true 3 | http-server.http.port=8080 4 | discovery.uri=http://localhost:8080 5 | -------------------------------------------------------------------------------- /docker/trino/catalog/iceberg.properties: -------------------------------------------------------------------------------- 1 | connector.name=iceberg 2 | hive.metastore.uri=thrift://hive-metastore:9083 3 | hive.s3.endpoint=http://minio:9000 4 | hive.s3.path-style-access=true 5 | hive.s3.aws-access-key=minio 6 | hive.s3.aws-secret-key=minio123 7 | hive.metastore-cache-ttl=0s 8 | hive.metastore-refresh-interval=5s 9 | hive.metastore-timeout=10s 10 | iceberg.experimental.extended-statistics.enabled=true 11 | -------------------------------------------------------------------------------- /docker/trino/etc/jvm.config: -------------------------------------------------------------------------------- 1 | -server 2 | -XX:InitialRAMPercentage=80 3 | -XX:MaxRAMPercentage=80 4 | -XX:G1HeapRegionSize=32M 5 | -XX:+ExplicitGCInvokesConcurrent 6 | -XX:+HeapDumpOnOutOfMemoryError 7 | -XX:+ExitOnOutOfMemoryError 8 | -XX:-OmitStackTraceInFastThrow 9 | -XX:ReservedCodeCacheSize=256M 10 | -XX:PerMethodRecompilationCutoff=10000 11 | -XX:PerBytecodeRecompilationCutoff=10000 12 | -Djdk.attach.allowAttachSelf=true 13 | -Djdk.nio.maxCachedBufferSize=2000000 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "trino-iceberg-maintenance" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Michiel De Smet "] 6 | readme = "README.md" 7 | packages = [{include = "trino_iceberg_maintenance"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.9" 11 | trino = "^0.320.0" 12 | 13 | [tool.poetry.group.dev.dependencies] 14 | pytest = "^7.2.0" 15 | testcontainers = "^3.7.1" 16 | freezegun = "^1.2.2" 17 | 18 | [build-system] 19 | requires = ["poetry-core"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import pytest 5 | import trino.dbapi 6 | from testcontainers.compose import DockerCompose 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def trino_server(): 11 | package_root_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") 12 | with DockerCompose(package_root_directory, compose_file_name="docker-compose-trino.yml", pull=True) as compose: 13 | host = compose.get_service_host("trino", 8080) 14 | port = compose.get_service_port("trino", 8080) 15 | timeout_start = time.time() 16 | while time.time() < timeout_start + 30: 17 | stdout, stderr = compose.get_logs() 18 | if stderr: 19 | raise RuntimeError(f"""Could not start Trino container: {stderr.decode("utf-8")}""") 20 | if "SERVER STARTED" in stdout.decode("utf-8"): 21 | yield host, port 22 | return 23 | time.sleep(2) 24 | raise RuntimeError("Timeout exceeded: Could not start Trino container") 25 | 26 | 27 | @pytest.fixture(scope="session") 28 | def trino_connection(trino_server): 29 | host, port = trino_server 30 | yield trino.dbapi.connect( 31 | host=host, 32 | port=port, 33 | user="admin", 34 | catalog="iceberg", 35 | schema="default", 36 | experimental_python_types=True 37 | ) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trino-iceberg-maintenance 2 | 3 | This utility tool will run maintenance tasks on your Iceberg tables. 4 | 5 | It will: 6 | 7 | * Remove any orphan files that exceed the configured retention 8 | * Expire snapshots that exceed the configured retention 9 | * analyze table or specific columns every configured period 10 | * optimize table every configured period 11 | 12 | ## Configure the maintenance 13 | 14 | The configuration is stored in the `iceberg_maintenance_schedule` table. 15 | 16 | ``` 17 | table_name VARCHAR NOT NULL, 18 | should_analyze INTEGER, 19 | last_analyzed_on TIMESTAMP(6), 20 | days_to_analyze INTEGER, 21 | columns_to_analyze ARRAY(VARCHAR), 22 | should_optimize INTEGER, 23 | last_optimized_on TIMESTAMP(6), 24 | days_to_optimize INTEGER, 25 | should_expire_snapshots INTEGER, 26 | retention_days_snapshots INTEGER, 27 | should_remove_orphan_files INTEGER, 28 | retention_days_orphan_files INTEGER 29 | ``` 30 | 31 | The job can be scheduled using cron and configured through environment variables (currently only basic authentication is supported). 32 | 33 | It requires the latest trino-python-client to be installed. 34 | 35 | The amount of simultaneous maintenance jobs can be configured through setting the `NUM_WORKERS` environment variable (default is 5). 36 | 37 | ```bash 38 | export NUM_WORKERS=10 39 | export TRINO_HOST=localhost 40 | export TRINO_PORT=8080 41 | export TRINO_USER=admin 42 | export TRINO_PASSWORD=... 43 | export TRINO_CATALOG=iceberg 44 | export TRINO_SCHEMA=default 45 | python -m trino_iceberg_maintenance 46 | ``` 47 | -------------------------------------------------------------------------------- /docker-compose-trino.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | trino: 4 | ports: 5 | - "8080:8080" 6 | image: "trinodb/trino:398" 7 | volumes: 8 | - ./docker/trino/etc:/usr/lib/trino/etc:ro 9 | - ./docker/trino/catalog:/etc/trino/catalog 10 | 11 | metastore_db: 12 | image: postgres:11 13 | hostname: metastore_db 14 | environment: 15 | POSTGRES_USER: hive 16 | POSTGRES_PASSWORD: hive 17 | POSTGRES_DB: metastore 18 | 19 | hive-metastore: 20 | hostname: hive-metastore 21 | image: 'starburstdata/hive:3.1.2-e.18' 22 | ports: 23 | - '9083:9083' # Metastore Thrift 24 | environment: 25 | HIVE_METASTORE_DRIVER: org.postgresql.Driver 26 | HIVE_METASTORE_JDBC_URL: jdbc:postgresql://metastore_db:5432/metastore 27 | HIVE_METASTORE_USER: hive 28 | HIVE_METASTORE_PASSWORD: hive 29 | HIVE_METASTORE_WAREHOUSE_DIR: s3://datalake/ 30 | S3_ENDPOINT: http://minio:9000 31 | S3_ACCESS_KEY: minio 32 | S3_SECRET_KEY: minio123 33 | S3_PATH_STYLE_ACCESS: "true" 34 | REGION: "" 35 | GOOGLE_CLOUD_KEY_FILE_PATH: "" 36 | AZURE_ADL_CLIENT_ID: "" 37 | AZURE_ADL_CREDENTIAL: "" 38 | AZURE_ADL_REFRESH_URL: "" 39 | AZURE_ABFS_STORAGE_ACCOUNT: "" 40 | AZURE_ABFS_ACCESS_KEY: "" 41 | AZURE_WASB_STORAGE_ACCOUNT: "" 42 | AZURE_ABFS_OAUTH: "" 43 | AZURE_ABFS_OAUTH_TOKEN_PROVIDER: "" 44 | AZURE_ABFS_OAUTH_CLIENT_ID: "" 45 | AZURE_ABFS_OAUTH_SECRET: "" 46 | AZURE_ABFS_OAUTH_ENDPOINT: "" 47 | AZURE_WASB_ACCESS_KEY: "" 48 | HIVE_METASTORE_USERS_IN_ADMIN_ROLE: "admin" 49 | depends_on: 50 | - metastore_db 51 | 52 | minio: 53 | hostname: minio 54 | image: 'minio/minio:RELEASE.2022-05-26T05-48-41Z' 55 | container_name: minio 56 | ports: 57 | - '9000:9000' 58 | - '9001:9001' 59 | environment: 60 | MINIO_ACCESS_KEY: minio 61 | MINIO_SECRET_KEY: minio123 62 | command: server /data --console-address ":9001" 63 | 64 | # This job will create the "datalake" bucket on Minio 65 | mc-job: 66 | image: 'minio/mc:RELEASE.2022-05-09T04-08-26Z' 67 | entrypoint: | 68 | /bin/bash -c " 69 | sleep 5; 70 | /usr/bin/mc config --quiet host add myminio http://minio:9000 minio minio123; 71 | /usr/bin/mc mb --quiet myminio/datalake 72 | " 73 | depends_on: 74 | - minio 75 | 76 | networks: 77 | default: 78 | name: dbt-net 79 | -------------------------------------------------------------------------------- /tests/test_maintenance.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | from textwrap import dedent 4 | 5 | import pytest 6 | import trino.dbapi 7 | from freezegun import freeze_time 8 | 9 | from trino_iceberg_maintenance.__main__ import ( 10 | create_if_not_exists_management_table, 11 | run_maintenance, 12 | MAINTENANCE_TABLE, 13 | ) 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def run_before_and_after_tests(trino_connection): 18 | create_if_not_exists_management_table(trino_connection) 19 | yield 20 | trino_connection.cursor().execute(f"DROP TABLE {MAINTENANCE_TABLE}") 21 | 22 | 23 | def connection_factory(host, port): 24 | def factory(): 25 | return trino.dbapi.connect( 26 | host=host, 27 | port=port, 28 | user="admin", 29 | catalog="iceberg", 30 | schema="default", 31 | experimental_python_types=True, 32 | ) 33 | 34 | return factory 35 | 36 | 37 | def create_random_suffix(): 38 | return uuid.uuid4().hex 39 | 40 | 41 | def test_optimize(trino_connection, trino_server): 42 | host, port = trino_server 43 | cur = trino_connection.cursor() 44 | table_name = "t_" + create_random_suffix() 45 | cur.execute(f"CREATE TABLE {table_name} (a VARCHAR, b VARCHAR)") 46 | 47 | # Let's create two files 48 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES ('a', 'b')") 49 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES ('a', 'b')") 50 | cur.execute(f"""SELECT * from "{table_name}$files" """) 51 | assert len(cur.fetchall()) == 2 52 | 53 | # Running maintenance without config shouldn't do anything 54 | run_maintenance(trino_connection, connection_factory(host, port)) 55 | 56 | cur.execute(f"""SELECT * from "{table_name}$files" """) 57 | assert len(cur.fetchall()) == 2 58 | 59 | # Configuring maintenance 60 | cur.execute(dedent(f""" 61 | INSERT INTO {MAINTENANCE_TABLE} (table_name, should_optimize, days_to_optimize) 62 | VALUES ('{table_name}', 1, 10)""")) 63 | 64 | run_maintenance(trino_connection, connection_factory(host, port)) 65 | 66 | cur.execute(f"""SELECT * from "{table_name}$files" """) 67 | assert len(cur.fetchall()) == 1 68 | 69 | # Running maintenance again shouldn't optimize again 70 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES ('a', 'b')") 71 | cur.execute(f"""SELECT * from "{table_name}$files" """) 72 | assert len(cur.fetchall()) == 2 73 | 74 | # It should run after the configured delta 75 | with freeze_time(datetime.datetime.now() + datetime.timedelta(days=11)): 76 | run_maintenance(trino_connection, connection_factory(host, port)) 77 | cur.execute(f"""SELECT * from "{table_name}$files" """) 78 | assert len(cur.fetchall()) == 1 79 | 80 | 81 | def test_analyze_without_colums(trino_connection, trino_server): 82 | host, port = trino_server 83 | cur = trino_connection.cursor() 84 | table_name = "t_" + create_random_suffix() 85 | cur.execute(f"CREATE TABLE {table_name} (a VARCHAR, b VARCHAR)") 86 | 87 | # Let's create two files 88 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES (NULL, NULL)") 89 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES (NULL, NULL)") 90 | cur.execute(f"SHOW STATS FOR {table_name}") 91 | rows = cur.fetchall() 92 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 93 | 94 | # Running maintenance without config shouldn't do anything 95 | run_maintenance(trino_connection, connection_factory(host, port)) 96 | 97 | cur.execute(f"SHOW STATS FOR {table_name}") 98 | rows = cur.fetchall() 99 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 100 | 101 | # Configuring maintenance 102 | cur.execute(dedent(f""" 103 | INSERT INTO {MAINTENANCE_TABLE} (table_name, should_analyze, days_to_analyze) 104 | VALUES ('{table_name}', 1, 10)""")) 105 | 106 | run_maintenance(trino_connection, connection_factory(host, port)) 107 | 108 | cur.execute(f"SHOW STATS FOR {table_name}") 109 | rows = cur.fetchall() 110 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 111 | 112 | # Running maintenance again shouldn't optimize again 113 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES ('a', 'b')") 114 | cur.execute(f"SHOW STATS FOR {table_name}") 115 | rows = cur.fetchall() 116 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 117 | 118 | # It should run after the configured delta 119 | with freeze_time(datetime.datetime.now() + datetime.timedelta(days=11)): 120 | run_maintenance(trino_connection, connection_factory(host, port)) 121 | cur.execute(f"SHOW STATS FOR {table_name}") 122 | rows = cur.fetchall() 123 | assert rows[0][3] == 0.6666666666666666 124 | 125 | 126 | def test_analyze_with_colums(trino_connection, trino_server): 127 | host, port = trino_server 128 | cur = trino_connection.cursor() 129 | table_name = "t_" + create_random_suffix() 130 | cur.execute(f"CREATE TABLE {table_name} (a VARCHAR, b VARCHAR)") 131 | 132 | # Let's create two files 133 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES (NULL, NULL)") 134 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES (NULL, NULL)") 135 | cur.execute(f"SHOW STATS FOR {table_name}") 136 | assert cur.fetchall()[0][3] == 1.0 # null fraction should be 1 for column a 137 | 138 | # Running maintenance without config shouldn't do anything 139 | run_maintenance(trino_connection, connection_factory(host, port)) 140 | 141 | cur.execute(f"SHOW STATS FOR {table_name}") 142 | assert cur.fetchall()[0][3] == 1.0 # null fraction should be 1 for column a 143 | 144 | # Configuring maintenance 145 | cur.execute(dedent(f""" 146 | INSERT INTO {MAINTENANCE_TABLE} (table_name, should_analyze, days_to_analyze, columns_to_analyze) 147 | VALUES ('{table_name}', 1, 10, ARRAY['a'])""")) 148 | 149 | run_maintenance(trino_connection, connection_factory(host, port)) 150 | 151 | cur.execute(f"SHOW STATS FOR {table_name}") 152 | rows = cur.fetchall() 153 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 154 | assert rows[1][3] == 1.0 # null fraction should be 1 for column b 155 | 156 | # Running maintenance again shouldn't optimize again 157 | cur.execute(f"INSERT INTO {table_name} (a, b) VALUES ('a', 'b')") 158 | cur.execute(f"SHOW STATS FOR {table_name}") 159 | rows = cur.fetchall() 160 | assert rows[0][3] == 1.0 # null fraction should be 1 for column a 161 | assert rows[1][3] == 0.6666666666666666 # null fraction should be 1 for column b 162 | 163 | # It should run after the configured delta 164 | with freeze_time(datetime.datetime.now() + datetime.timedelta(days=11)): 165 | run_maintenance(trino_connection, connection_factory(host, port)) 166 | cur.execute(f"SHOW STATS FOR {table_name}") 167 | rows = cur.fetchall() 168 | assert rows[0][3] == 0.6666666666666666 169 | assert rows[1][3] == 0.6666666666666666 170 | -------------------------------------------------------------------------------- /trino_iceberg_maintenance/__main__.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import threading 5 | from concurrent.futures import ThreadPoolExecutor, as_completed 6 | from textwrap import dedent 7 | from typing import NamedTuple, List, Optional 8 | 9 | import trino.auth 10 | from trino.dbapi import connect 11 | 12 | # The number of maintenance jobs you want to run at the same time 13 | NUM_WORKERS = os.getenv("NUM_WORKERS", 5) 14 | # The table that contains the maintenance configuration 15 | MAINTENANCE_TABLE = os.getenv("MAINTENANCE_TABLE", "iceberg_maintenance_schedule") 16 | 17 | logger = logging.getLogger("IcebergMaintenance") 18 | maintenance_table_lock = threading.RLock() 19 | 20 | 21 | def get_trino_connection(): 22 | user = os.getenv("TRINO_USER", "admin") 23 | password = os.getenv("TRINO_PASSWORD") 24 | host = os.getenv("TRINO_HOST", "localhost") 25 | port = os.getenv("TRINO_PORT", 8080) 26 | catalog = os.getenv("TRINO_CATALOG", "iceberg") 27 | schema = os.getenv("TRINO_SCHEMA", "default") 28 | return connect( 29 | host=host, 30 | port=int(port), 31 | user=user, 32 | auth=trino.auth.BasicAuthentication(user, password) if password is not None else None, 33 | catalog=catalog, 34 | schema=schema, 35 | experimental_python_types=True, 36 | http_scheme="https" if password is not None else "http" 37 | ) 38 | 39 | 40 | def create_if_not_exists_management_table(trino_connection): 41 | create_table_statement = dedent(f""" 42 | CREATE TABLE IF NOT EXISTS {MAINTENANCE_TABLE} ( 43 | table_name VARCHAR NOT NULL, 44 | should_analyze INTEGER, 45 | last_analyzed_on TIMESTAMP(6), 46 | days_to_analyze INTEGER, 47 | columns_to_analyze ARRAY(VARCHAR), 48 | should_optimize INTEGER, 49 | last_optimized_on TIMESTAMP(6), 50 | days_to_optimize INTEGER, 51 | should_expire_snapshots INTEGER, 52 | retention_days_snapshots INTEGER, 53 | should_remove_orphan_files INTEGER, 54 | retention_days_orphan_files INTEGER 55 | )""") 56 | cursor = trino_connection.cursor() 57 | cursor.execute(create_table_statement) 58 | 59 | 60 | def run_maintenance(trino_connection, connection_factory): 61 | cur = trino_connection.cursor() 62 | cur.execute(f"SELECT * FROM {MAINTENANCE_TABLE}") 63 | tasks = cur.fetchall() 64 | 65 | with ThreadPoolExecutor(max_workers=int(NUM_WORKERS)) as executor: 66 | futures = [] 67 | for task in tasks: 68 | futures.append(executor.submit(MaintenanceTask( 69 | connection_factory, 70 | MaintenanceProperties.from_row(task) 71 | ).execute)) 72 | 73 | for future in as_completed(futures): 74 | try: 75 | future.result() 76 | except MaintenanceTaskException as e: 77 | logger.exception( 78 | "An exception has occurred while running maintenance tasks for " 79 | f"{e.maintenance_properties.table_name}" 80 | ) 81 | 82 | 83 | class MaintenanceProperties(NamedTuple): 84 | table_name: str 85 | should_analyze: bool 86 | last_analyzed_on: Optional[datetime.datetime] 87 | days_to_analyze: int 88 | columns_to_analyze: List[str] 89 | should_optimize: bool 90 | last_optimized_on: Optional[datetime.datetime] 91 | days_to_optimize: int 92 | should_expire_snapshots: bool 93 | retention_days_snapshots: int 94 | should_remove_orphan_files: bool 95 | retention_days_orphan_files: int 96 | 97 | @classmethod 98 | def from_row(cls, row): 99 | return cls(*row) 100 | 101 | 102 | class MaintenanceTaskException(Exception): 103 | def __init__( 104 | self, 105 | maintenance_properties: MaintenanceProperties, 106 | message="An exception occurred while running maintenance tasks" 107 | ): 108 | self.maintenance_properties = maintenance_properties 109 | super().__init__(message) 110 | 111 | 112 | class MaintenanceTask: 113 | def __init__( 114 | self, 115 | connection_factory, 116 | maintenance_properties: MaintenanceProperties 117 | ): 118 | self.connection_factory = connection_factory 119 | self.maintenance_properties = maintenance_properties 120 | 121 | def execute(self): 122 | ( 123 | table_name, 124 | should_analyze, 125 | last_analyzed_on, 126 | days_to_analyze, 127 | columns_to_analyze, 128 | should_optimize, 129 | last_optimized_on, 130 | days_to_optimize, 131 | should_expire_snapshots, 132 | retention_days_snapshots, 133 | should_remove_orphan_files, 134 | retention_days_orphan_files, 135 | 136 | ) = self.maintenance_properties 137 | try: 138 | with self.connection_factory() as conn: 139 | cur = conn.cursor() 140 | # Removing orphan files 141 | if should_remove_orphan_files: 142 | logging.info(f"Removing orphan files for {table_name}") 143 | 144 | cur.execute(dedent(f""" 145 | ALTER TABLE {table_name} EXECUTE remove_orphan_files( 146 | retention_threshold => '{retention_days_orphan_files}d' 147 | )""")) 148 | logging.info(f"Removing orphan files for {table_name} completed") 149 | 150 | # Expiring snapshots 151 | if should_expire_snapshots: 152 | logging.info(f"Expiring snapshots for {table_name}") 153 | 154 | cur.execute(dedent(f""" 155 | ALTER TABLE {table_name} EXECUTE expire_snapshots( 156 | retention_threshold => '{retention_days_snapshots}d' 157 | )""")) 158 | logging.info(f"Expiring snapshots for {table_name} completed") 159 | 160 | # Optimizing 161 | if ( 162 | should_optimize 163 | and ( 164 | not last_optimized_on 165 | or last_optimized_on + datetime.timedelta(days=days_to_optimize) <= datetime.datetime.now() 166 | ) 167 | ): 168 | logging.info(f"Optimizing {table_name}") 169 | 170 | cur.execute(f"ALTER TABLE {table_name} EXECUTE optimize") 171 | with maintenance_table_lock: 172 | cur.execute(dedent(f""" 173 | UPDATE {MAINTENANCE_TABLE} 174 | SET last_optimized_on = current_timestamp(6) 175 | WHERE table_name = '{table_name}' 176 | """)) 177 | logging.info(f"Optimizing {table_name} completed") 178 | 179 | # Analyzing 180 | if ( 181 | should_analyze 182 | and ( 183 | not last_analyzed_on 184 | or last_analyzed_on + datetime.timedelta(days=days_to_analyze) <= datetime.datetime.now() 185 | ) 186 | ): 187 | logging.info(f"Analyzing {table_name}") 188 | with_columns = "" if columns_to_analyze is None or len(columns_to_analyze) == 0 else f""" 189 | WITH (columns = ARRAY[{', '.join(map(lambda x: f"'{x}'", columns_to_analyze))}])""" 190 | cur.execute(dedent(f""" 191 | ANALYZE {table_name} 192 | {with_columns}""")) 193 | with maintenance_table_lock: 194 | cur.execute(dedent(f""" 195 | UPDATE {MAINTENANCE_TABLE} 196 | SET last_analyzed_on = current_timestamp(6) 197 | WHERE table_name = '{table_name}' 198 | """)) 199 | logging.info(f"Analyzing {table_name} completed") 200 | except Exception as e: 201 | raise MaintenanceTaskException(self.maintenance_properties) from e 202 | 203 | 204 | if __name__ == '__main__': 205 | with get_trino_connection() as conn: 206 | create_if_not_exists_management_table(conn) 207 | run_maintenance(conn, get_trino_connection) 208 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "attrs" 3 | version = "22.1.0" 4 | description = "Classes Without Boilerplate" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.5" 8 | 9 | [package.extras] 10 | dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] 11 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 12 | tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] 13 | tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] 14 | 15 | [[package]] 16 | name = "certifi" 17 | version = "2022.12.7" 18 | description = "Python package for providing Mozilla's CA Bundle." 19 | category = "main" 20 | optional = false 21 | python-versions = ">=3.6" 22 | 23 | [[package]] 24 | name = "charset-normalizer" 25 | version = "2.1.1" 26 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 27 | category = "main" 28 | optional = false 29 | python-versions = ">=3.6.0" 30 | 31 | [package.extras] 32 | unicode-backport = ["unicodedata2"] 33 | 34 | [[package]] 35 | name = "colorama" 36 | version = "0.4.6" 37 | description = "Cross-platform colored terminal text." 38 | category = "dev" 39 | optional = false 40 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 41 | 42 | [[package]] 43 | name = "deprecation" 44 | version = "2.1.0" 45 | description = "A library to handle automated deprecations" 46 | category = "dev" 47 | optional = false 48 | python-versions = "*" 49 | 50 | [package.dependencies] 51 | packaging = "*" 52 | 53 | [[package]] 54 | name = "docker" 55 | version = "6.0.1" 56 | description = "A Python library for the Docker Engine API." 57 | category = "dev" 58 | optional = false 59 | python-versions = ">=3.7" 60 | 61 | [package.dependencies] 62 | packaging = ">=14.0" 63 | pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} 64 | requests = ">=2.26.0" 65 | urllib3 = ">=1.26.0" 66 | websocket-client = ">=0.32.0" 67 | 68 | [package.extras] 69 | ssh = ["paramiko (>=2.4.3)"] 70 | 71 | [[package]] 72 | name = "exceptiongroup" 73 | version = "1.0.4" 74 | description = "Backport of PEP 654 (exception groups)" 75 | category = "dev" 76 | optional = false 77 | python-versions = ">=3.7" 78 | 79 | [package.extras] 80 | test = ["pytest (>=6)"] 81 | 82 | [[package]] 83 | name = "freezegun" 84 | version = "1.2.2" 85 | description = "Let your Python tests travel through time" 86 | category = "dev" 87 | optional = false 88 | python-versions = ">=3.6" 89 | 90 | [package.dependencies] 91 | python-dateutil = ">=2.7" 92 | 93 | [[package]] 94 | name = "idna" 95 | version = "3.4" 96 | description = "Internationalized Domain Names in Applications (IDNA)" 97 | category = "main" 98 | optional = false 99 | python-versions = ">=3.5" 100 | 101 | [[package]] 102 | name = "iniconfig" 103 | version = "1.1.1" 104 | description = "iniconfig: brain-dead simple config-ini parsing" 105 | category = "dev" 106 | optional = false 107 | python-versions = "*" 108 | 109 | [[package]] 110 | name = "packaging" 111 | version = "22.0" 112 | description = "Core utilities for Python packages" 113 | category = "dev" 114 | optional = false 115 | python-versions = ">=3.7" 116 | 117 | [[package]] 118 | name = "pluggy" 119 | version = "1.0.0" 120 | description = "plugin and hook calling mechanisms for python" 121 | category = "dev" 122 | optional = false 123 | python-versions = ">=3.6" 124 | 125 | [package.extras] 126 | dev = ["pre-commit", "tox"] 127 | testing = ["pytest", "pytest-benchmark"] 128 | 129 | [[package]] 130 | name = "pytest" 131 | version = "7.2.0" 132 | description = "pytest: simple powerful testing with Python" 133 | category = "dev" 134 | optional = false 135 | python-versions = ">=3.7" 136 | 137 | [package.dependencies] 138 | attrs = ">=19.2.0" 139 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 140 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 141 | iniconfig = "*" 142 | packaging = "*" 143 | pluggy = ">=0.12,<2.0" 144 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 145 | 146 | [package.extras] 147 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 148 | 149 | [[package]] 150 | name = "python-dateutil" 151 | version = "2.8.2" 152 | description = "Extensions to the standard Python datetime module" 153 | category = "dev" 154 | optional = false 155 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 156 | 157 | [package.dependencies] 158 | six = ">=1.5" 159 | 160 | [[package]] 161 | name = "pytz" 162 | version = "2022.7" 163 | description = "World timezone definitions, modern and historical" 164 | category = "main" 165 | optional = false 166 | python-versions = "*" 167 | 168 | [[package]] 169 | name = "pywin32" 170 | version = "305" 171 | description = "Python for Window Extensions" 172 | category = "dev" 173 | optional = false 174 | python-versions = "*" 175 | 176 | [[package]] 177 | name = "requests" 178 | version = "2.28.1" 179 | description = "Python HTTP for Humans." 180 | category = "main" 181 | optional = false 182 | python-versions = ">=3.7, <4" 183 | 184 | [package.dependencies] 185 | certifi = ">=2017.4.17" 186 | charset-normalizer = ">=2,<3" 187 | idna = ">=2.5,<4" 188 | urllib3 = ">=1.21.1,<1.27" 189 | 190 | [package.extras] 191 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 192 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 193 | 194 | [[package]] 195 | name = "six" 196 | version = "1.16.0" 197 | description = "Python 2 and 3 compatibility utilities" 198 | category = "dev" 199 | optional = false 200 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 201 | 202 | [[package]] 203 | name = "testcontainers" 204 | version = "3.7.1" 205 | description = "Library provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container" 206 | category = "dev" 207 | optional = false 208 | python-versions = ">=3.7" 209 | 210 | [package.dependencies] 211 | deprecation = "*" 212 | docker = ">=4.0.0" 213 | wrapt = "*" 214 | 215 | [package.extras] 216 | arangodb = ["python-arango"] 217 | azurite = ["azure-storage-blob"] 218 | clickhouse = ["clickhouse-driver"] 219 | docker-compose = ["docker-compose"] 220 | google-cloud-pubsub = ["google-cloud-pubsub (<2)"] 221 | kafka = ["kafka-python"] 222 | keycloak = ["python-keycloak"] 223 | mongo = ["pymongo"] 224 | mssqlserver = ["pymssql"] 225 | mysql = ["pymysql", "sqlalchemy"] 226 | neo4j = ["neo4j"] 227 | oracle = ["cx-Oracle", "sqlalchemy"] 228 | postgresql = ["psycopg2-binary", "sqlalchemy"] 229 | rabbitmq = ["pika"] 230 | redis = ["redis"] 231 | selenium = ["selenium"] 232 | 233 | [[package]] 234 | name = "tomli" 235 | version = "2.0.1" 236 | description = "A lil' TOML parser" 237 | category = "dev" 238 | optional = false 239 | python-versions = ">=3.7" 240 | 241 | [[package]] 242 | name = "trino" 243 | version = "0.320.0" 244 | description = "Client for the Trino distributed SQL Engine" 245 | category = "main" 246 | optional = false 247 | python-versions = ">=3.7" 248 | 249 | [package.dependencies] 250 | pytz = "*" 251 | requests = "*" 252 | 253 | [package.extras] 254 | all = ["requests-kerberos", "sqlalchemy (>=1.3,<2.0)"] 255 | external-authentication-token-cache = ["keyring"] 256 | kerberos = ["requests-kerberos"] 257 | sqlalchemy = ["sqlalchemy (>=1.3,<2.0)"] 258 | tests = ["black", "click", "httpretty (<1.1)", "isort", "pre-commit", "pytest", "pytest-runner", "requests-kerberos", "sqlalchemy (>=1.3,<2.0)", "sqlalchemy-utils"] 259 | 260 | [[package]] 261 | name = "urllib3" 262 | version = "1.26.13" 263 | description = "HTTP library with thread-safe connection pooling, file post, and more." 264 | category = "main" 265 | optional = false 266 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 267 | 268 | [package.extras] 269 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 270 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 271 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 272 | 273 | [[package]] 274 | name = "websocket-client" 275 | version = "1.4.2" 276 | description = "WebSocket client for Python with low level API options" 277 | category = "dev" 278 | optional = false 279 | python-versions = ">=3.7" 280 | 281 | [package.extras] 282 | docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] 283 | optional = ["python-socks", "wsaccel"] 284 | test = ["websockets"] 285 | 286 | [[package]] 287 | name = "wrapt" 288 | version = "1.14.1" 289 | description = "Module for decorators, wrappers and monkey patching." 290 | category = "dev" 291 | optional = false 292 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 293 | 294 | [metadata] 295 | lock-version = "1.1" 296 | python-versions = "^3.9" 297 | content-hash = "a06dafa0ab50f2c9749debb2c6add84b1295204781b4b5aca39f626473607b1b" 298 | 299 | [metadata.files] 300 | attrs = [ 301 | {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, 302 | {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, 303 | ] 304 | certifi = [ 305 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 306 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 307 | ] 308 | charset-normalizer = [ 309 | {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, 310 | {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, 311 | ] 312 | colorama = [ 313 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 314 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 315 | ] 316 | deprecation = [ 317 | {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, 318 | {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, 319 | ] 320 | docker = [ 321 | {file = "docker-6.0.1-py3-none-any.whl", hash = "sha256:dbcb3bd2fa80dca0788ed908218bf43972772009b881ed1e20dfc29a65e49782"}, 322 | {file = "docker-6.0.1.tar.gz", hash = "sha256:896c4282e5c7af5c45e8b683b0b0c33932974fe6e50fc6906a0a83616ab3da97"}, 323 | ] 324 | exceptiongroup = [ 325 | {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, 326 | {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, 327 | ] 328 | freezegun = [ 329 | {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, 330 | {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, 331 | ] 332 | idna = [ 333 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 334 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 335 | ] 336 | iniconfig = [ 337 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 338 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 339 | ] 340 | packaging = [ 341 | {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, 342 | {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, 343 | ] 344 | pluggy = [ 345 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 346 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 347 | ] 348 | pytest = [ 349 | {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, 350 | {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, 351 | ] 352 | python-dateutil = [ 353 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 354 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 355 | ] 356 | pytz = [ 357 | {file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"}, 358 | {file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"}, 359 | ] 360 | pywin32 = [ 361 | {file = "pywin32-305-cp310-cp310-win32.whl", hash = "sha256:421f6cd86e84bbb696d54563c48014b12a23ef95a14e0bdba526be756d89f116"}, 362 | {file = "pywin32-305-cp310-cp310-win_amd64.whl", hash = "sha256:73e819c6bed89f44ff1d690498c0a811948f73777e5f97c494c152b850fad478"}, 363 | {file = "pywin32-305-cp310-cp310-win_arm64.whl", hash = "sha256:742eb905ce2187133a29365b428e6c3b9001d79accdc30aa8969afba1d8470f4"}, 364 | {file = "pywin32-305-cp311-cp311-win32.whl", hash = "sha256:19ca459cd2e66c0e2cc9a09d589f71d827f26d47fe4a9d09175f6aa0256b51c2"}, 365 | {file = "pywin32-305-cp311-cp311-win_amd64.whl", hash = "sha256:326f42ab4cfff56e77e3e595aeaf6c216712bbdd91e464d167c6434b28d65990"}, 366 | {file = "pywin32-305-cp311-cp311-win_arm64.whl", hash = "sha256:4ecd404b2c6eceaca52f8b2e3e91b2187850a1ad3f8b746d0796a98b4cea04db"}, 367 | {file = "pywin32-305-cp36-cp36m-win32.whl", hash = "sha256:48d8b1659284f3c17b68587af047d110d8c44837736b8932c034091683e05863"}, 368 | {file = "pywin32-305-cp36-cp36m-win_amd64.whl", hash = "sha256:13362cc5aa93c2beaf489c9c9017c793722aeb56d3e5166dadd5ef82da021fe1"}, 369 | {file = "pywin32-305-cp37-cp37m-win32.whl", hash = "sha256:a55db448124d1c1484df22fa8bbcbc45c64da5e6eae74ab095b9ea62e6d00496"}, 370 | {file = "pywin32-305-cp37-cp37m-win_amd64.whl", hash = "sha256:109f98980bfb27e78f4df8a51a8198e10b0f347257d1e265bb1a32993d0c973d"}, 371 | {file = "pywin32-305-cp38-cp38-win32.whl", hash = "sha256:9dd98384da775afa009bc04863426cb30596fd78c6f8e4e2e5bbf4edf8029504"}, 372 | {file = "pywin32-305-cp38-cp38-win_amd64.whl", hash = "sha256:56d7a9c6e1a6835f521788f53b5af7912090674bb84ef5611663ee1595860fc7"}, 373 | {file = "pywin32-305-cp39-cp39-win32.whl", hash = "sha256:9d968c677ac4d5cbdaa62fd3014ab241718e619d8e36ef8e11fb930515a1e918"}, 374 | {file = "pywin32-305-cp39-cp39-win_amd64.whl", hash = "sha256:50768c6b7c3f0b38b7fb14dd4104da93ebced5f1a50dc0e834594bff6fbe1271"}, 375 | ] 376 | requests = [ 377 | {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, 378 | {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, 379 | ] 380 | six = [ 381 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 382 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 383 | ] 384 | testcontainers = [ 385 | {file = "testcontainers-3.7.1-py2.py3-none-any.whl", hash = "sha256:7f48cef4bf0ccd78f1a4534d4b701a003a3bace851f24eae58a32f9e3f0aeba0"}, 386 | ] 387 | tomli = [ 388 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 389 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 390 | ] 391 | trino = [ 392 | {file = "trino-0.320.0-py3-none-any.whl", hash = "sha256:3ebf2a8371f530fe286d03c27856a4bfbb314451a966e59148e0a2a7379a4539"}, 393 | {file = "trino-0.320.0.tar.gz", hash = "sha256:0d34a1380de1e447df2d96994bd11e1cd6adba6691a7104b0a8b2d649c4bfeb9"}, 394 | ] 395 | urllib3 = [ 396 | {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, 397 | {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, 398 | ] 399 | websocket-client = [ 400 | {file = "websocket-client-1.4.2.tar.gz", hash = "sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59"}, 401 | {file = "websocket_client-1.4.2-py3-none-any.whl", hash = "sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574"}, 402 | ] 403 | wrapt = [ 404 | {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, 405 | {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, 406 | {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, 407 | {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, 408 | {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, 409 | {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, 410 | {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, 411 | {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, 412 | {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, 413 | {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, 414 | {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, 415 | {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, 416 | {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, 417 | {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, 418 | {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, 419 | {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, 420 | {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, 421 | {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, 422 | {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, 423 | {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, 424 | {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, 425 | {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, 426 | {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, 427 | {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, 428 | {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, 429 | {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, 430 | {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, 431 | {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, 432 | {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, 433 | {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, 434 | {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, 435 | {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, 436 | {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, 437 | {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, 438 | {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, 439 | {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, 440 | {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, 441 | {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, 442 | {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, 443 | {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, 444 | {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, 445 | {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, 446 | {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, 447 | {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, 448 | {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, 449 | {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, 450 | {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, 451 | {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, 452 | {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, 453 | {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, 454 | {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, 455 | {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, 456 | {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, 457 | {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, 458 | {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, 459 | {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, 460 | {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, 461 | {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, 462 | {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, 463 | {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, 464 | {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, 465 | {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, 466 | {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, 467 | {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, 468 | ] 469 | --------------------------------------------------------------------------------