From 50fded9d06ed0cff4ca06be6aebd4b0e2faf56f5 Mon Sep 17 00:00:00 2001 From: Alexander Rossmanith Date: Sun, 26 Jul 2026 16:04:51 +0530 Subject: [PATCH] initial commit --- .gitignore | 6 ++ networkcheck/main.py | 213 +++++++++++++++++++++++++++++++++++++++++++ pdm.lock | 208 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 15 +++ 4 files changed, 442 insertions(+) create mode 100644 .gitignore create mode 100644 networkcheck/main.py create mode 100644 pdm.lock create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0dd47c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.py[codz] +__pycache__/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.pdm-python diff --git a/networkcheck/main.py b/networkcheck/main.py new file mode 100644 index 0000000..726e722 --- /dev/null +++ b/networkcheck/main.py @@ -0,0 +1,213 @@ +import json +import logging +import socket + +import boto3 +import dns.resolver +import requests +from ping3 import ping + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +expected_ipv4 = ({ + "nas.local": "192.168.1.10", + "homeassistant.local": "192.168.1.11" +}) + +public_nameserver_ipv4 = "8.8.8.8" # Google DNS + +local_nameserver_ipv4 = "192.168.1.10" # NAS IPv4 DNS + +connectivity_test_addresses = ( + "192.168.1.1", # Router + "192.168.1.10", # NAS + "192.168.1.11", # Home Assistant on raspberry pi + "192.168.1.21", # camera + "192.168.1.22", # camera + "192.168.1.23", # camera + "192.168.1.24", # camera + "192.168.1.25", # camera + "192.168.1.26", # camera + "192.168.1.27", # camera + "192.168.1.28" # camera +) + +open_ports = ({ + "192.168.1.1": [80], # Router + "192.168.1.10": [80, 443, 5000, 5001], # NAS + "192.168.1.11": [8123], # Home Assistant on Raspberry Pi + "192.168.1.21": [80, 443], # camera + "192.168.1.22": [80, 443], # camera + "192.168.1.23": [80, 443], # camera + "192.168.1.24": [80, 443], # camera + "192.168.1.25": [80, 443], # camera + "192.168.1.26": [80, 443], # camera + "192.168.1.27": [80, 443], # camera + "192.168.1.28": [80, 443], # camera +}) + +local_dns_test_addresses = ( + "google.com", + "homeassistant.local", + "nas.local", + "homeassistant.vaishnavi.house", + "nas.vaishnavi.house", + "vaishnavi.house", + "rossmanith.family" +) + +public_dns_test_addresses = ( + "homeassistant.vaishnavi.house", + "nas.vaishnavi.house", + "vaishnavi.house", + "rossmanith.family" +) + +webserver_test_urls = ( + "https://rossmanith.family", + "https://vaishnavi.house", + "https://nas.vaishnavi.house", + "https://homeassistant.vaishnavi.house", +) + +def main(): + + logger.info("Starting network check...") + + access_key = "AKIAWVVF4JB3NPKIZGG5" + secret_key = "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" + lambda_client = boto3.client("lambda", region_name="eu-west-1", aws_access_key_id=access_key, aws_secret_access_key=secret_key) + + payload = { + "domains": ["rossmanith.family", "vaishnavi.house"] + } + + response = lambda_client.invoke( + FunctionName="arn:aws:lambda:eu-west-1:458835642486:function:domainCheck", + InvocationType="RequestResponse", + Payload=json.dumps(payload) + ) + + result = json.loads(response["Payload"].read()) + print(json.dumps(result, indent=2)) + + + + print() + logger.info("Checking IPv4 addresses...") + for host, expected_ip in expected_ipv4.items(): + check_ipv4(host, expected_ip) + + print() + logger.info("Checking connectivity...") + for addr in connectivity_test_addresses: + ping_check(addr) + + + print() + logger.info("Checking open ports...") + for addr, ports in open_ports.items(): + for port in ports: + check_port(addr, port) + + print() + public_ip = requests.get("https://api.ipify.org").text + logger.info("Public IP: %s", public_ip) + + print() + logger.info("Checking DNS resolution of local nameserver %s...", local_nameserver_ipv4) + for addr in local_dns_test_addresses: + check_dns(local_nameserver_ipv4, addr) + + print() + logger.info("Checking DNS resolution of public nameserver %s...", public_nameserver_ipv4) + for addr in public_dns_test_addresses: + check_dns(public_nameserver_ipv4, addr) + + print() + logger.info("Checking webserver availability from LAN...") + for url in webserver_test_urls: + check_webserver_lan(url) + + print() + logger.info("Checking webserver availability from WAN...") + for url in webserver_test_urls: + check_webserver_wan(url) + + +def check_ipv4(host, expected_ip): + """Check if the host resolves to the expected IPv4 address.""" + try: + resolved_ip = socket.gethostbyname(host) + if resolved_ip == expected_ip: + logger.info("✅ %s has correct IP: %s", host, resolved_ip) + else: + logger.warning("❌ %s IP mismatch: %s", host, resolved_ip) + except socket.gaierror as e: + logger.error("❌ %s DNS resolution error: %s", host, e) + + +def ping_check(host_ip): + """Ping the host to check connectivity.""" + try: + ping_time = ping(host_ip, timeout=2) + if ping_time is not None: + logger.info("✅ %s reachable (ping time: %.2f ms)", host_ip, ping_time * 1000) + except Exception as e: + logger.error("❌ %s unreachable: %s", host_ip, e) + +def check_dns(nameserver, addr): + """Check DNS resolution.""" + resolver = dns.resolver.Resolver() + resolver.nameservers = [nameserver] + try: + answer_ = resolver.resolve(addr, "A") + logger.info("✅ DNS %s can resolve %s: %s", nameserver, addr, [r.to_text() for r in answer_]) + except Exception as e: + logger.error("❌ DNS %s cannot resolve %s: %s", nameserver, addr, e) + +def check_webserver_lan(url): + try: + response = requests.get(url, timeout=5, verify=True) + if response.ok: + logger.info("✅ %s responded with %d", url, response.status_code) + else: + logger.warning("⚠️ %s responded with %d", url, response.status_code) + except requests.exceptions.SSLError as e: + logger.error("❌ SSL certificate error for %s: %s", url, e) + except requests.exceptions.ConnectionError: + logger.error("❌ Connection failed: %s not reachable.", url) + except requests.exceptions.Timeout: + logger.error("❌ Request timed out for %s.", url) + except Exception as e: + logger.error("❌ Unexpected error for %s: %s", url, e) + + +def check_webserver_wan(domain): + try: + # Use isup.me style check + check_url = f"https://isitup.org/{domain.replace('https://','')}.json" + resp = requests.get(check_url, timeout=10).json() + if resp["status_code"] == 1: + print(f"✅ {domain} is externally reachable") + else: + print(f"❌ {domain} is NOT reachable externally") + except Exception as e: + print(f"⚠️ Error checking {domain}: {e}") + + + +def check_port(addr, port): + """Check if a specific port is open on the host.""" + try: + with socket.create_connection((addr, port), timeout=2): + logger.info("✅ %s:%d is open", addr, port) + except (socket.timeout, ConnectionRefusedError): + logger.warning("⚠️ %s:%d is closed or filtered", addr, port) + except Exception as e: + logger.error("❌ Error checking %s:%d: %s", addr, port, e) + + +if __name__ == "__main__": + main() diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..c74f56a --- /dev/null +++ b/pdm.lock @@ -0,0 +1,208 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:9923bd97ecdd326d0dbea944894269ccf3cef942e362373674711618f8dd2bdb" + +[[metadata.targets]] +requires_python = "==3.14.*" + +[[package]] +name = "boto3" +version = "1.43.14" +requires_python = ">=3.10" +summary = "The AWS SDK for Python" +groups = ["default"] +dependencies = [ + "botocore<1.44.0,>=1.43.14", + "jmespath<2.0.0,>=0.7.1", + "s3transfer<0.18.0,>=0.17.0", +] +files = [ + {file = "boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4"}, + {file = "boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d"}, +] + +[[package]] +name = "botocore" +version = "1.43.14" +requires_python = ">=3.10" +summary = "Low-level, data-driven core of boto 3." +groups = ["default"] +dependencies = [ + "jmespath<2.0.0,>=0.7.1", + "python-dateutil<3.0.0,>=2.1", + "urllib3!=2.2.0,<3,>=1.25.4", +] +files = [ + {file = "botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658"}, + {file = "botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516"}, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +requires_python = ">=3.7" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default"] +files = [ + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +requires_python = ">=3.7" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +groups = ["default"] +files = [ + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +requires_python = ">=3.10" +summary = "DNS toolkit" +groups = ["default"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[[package]] +name = "idna" +version = "3.16" +requires_python = ">=3.9" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default"] +files = [ + {file = "idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5"}, + {file = "idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"}, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +requires_python = ">=3.9" +summary = "JSON Matching Expressions" +groups = ["default"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + +[[package]] +name = "ping3" +version = "5.1.5" +requires_python = ">=3.5" +summary = "A pure python3 version of ICMP ping implementation using raw socket." +groups = ["default"] +files = [ + {file = "ping3-5.1.5-py3-none-any.whl", hash = "sha256:9503164d054b87b01a6bd0df83a49c29e7c8427eb06940f874ebd4ed2d8ba8f6"}, + {file = "ping3-5.1.5.tar.gz", hash = "sha256:6c99bc844e0b7dbc5c9765e8b530140daf1ccd2112c99db01ab79831bd8081cd"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "requests" +version = "2.34.2" +requires_python = ">=3.10" +summary = "Python HTTP for Humans." +groups = ["default"] +dependencies = [ + "certifi>=2023.5.7", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.26", +] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[[package]] +name = "s3transfer" +version = "0.17.0" +requires_python = ">=3.10" +summary = "An Amazon S3 Transfer Manager" +groups = ["default"] +dependencies = [ + "botocore<2.0a.0,>=1.37.4", +] +files = [ + {file = "s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20"}, + {file = "s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a"}, +] + +[[package]] +name = "six" +version = "1.17.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +requires_python = ">=3.10" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +groups = ["default"] +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1945089 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "networkcheck" +version = "0.1.0" +description = "Default template for PDM package" +authors = [ + {name = "Alexander Rossmanith", email = ""}, +] +dependencies = ["dnspython>=2.8.0", "ping3>=5.1.5", "requests>=2.34.2", "boto3>=1.43.14"] +requires-python = "==3.14.*" +readme = "README.md" +license = {text = "MIT"} + + +[tool.pdm] +distribution = false