From 25f6ddee5721cf36d2e0f89cbadcd8f8f241d6a0 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:10:15 +0900 Subject: [PATCH 01/12] rps --- tileget/__main__.py | 120 +++++++++++++++++++++++++------------------- tileget/arg.py | 10 ++-- 2 files changed, 73 insertions(+), 57 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index 4aa1c28..576cc17 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -1,6 +1,7 @@ import asyncio import os import sqlite3 +import time import httpx import tiletanic @@ -8,6 +9,22 @@ from tileget.arg import parse_arg +class RateLimiter: + def __init__(self, rps: int): + self.rps = rps + self.interval = 1.0 / rps + self.last_request_time = 0.0 + self.lock = asyncio.Lock() + + async def acquire(self): + async with self.lock: + now = time.monotonic() + wait_time = self.last_request_time + self.interval - now + if wait_time > 0: + await asyncio.sleep(wait_time) + self.last_request_time = time.monotonic() + + async def fetch_data( client: httpx.AsyncClient, url: str, timeout: int = 5000 ) -> bytes | None: @@ -29,40 +46,40 @@ async def fetch_data( async def download_dir( client: httpx.AsyncClient, - semaphore: asyncio.Semaphore, + rate_limiter: RateLimiter, tile: tiletanic.Tile, tileurl: str, output_path: str, timeout: int = 5000, overwrite: bool = False, ): - async with semaphore: - ext = os.path.splitext(tileurl.split("?")[0])[-1] + await rate_limiter.acquire() + ext = os.path.splitext(tileurl.split("?")[0])[-1] - write_dir = os.path.join(output_path, str(tile.z), str(tile.x)) - write_filepath = os.path.join(write_dir, str(tile.y) + ext) + write_dir = os.path.join(output_path, str(tile.z), str(tile.x)) + write_filepath = os.path.join(write_dir, str(tile.y) + ext) - if os.path.exists(write_filepath) and not overwrite: - return + if os.path.exists(write_filepath) and not overwrite: + return - url = ( - tileurl.replace(r"{x}", str(tile.x)) - .replace(r"{y}", str(tile.y)) - .replace(r"{z}", str(tile.z)) - ) + url = ( + tileurl.replace(r"{x}", str(tile.x)) + .replace(r"{y}", str(tile.y)) + .replace(r"{z}", str(tile.z)) + ) - data = await fetch_data(client, url, timeout) - if data is None: - return + data = await fetch_data(client, url, timeout) + if data is None: + return - os.makedirs(write_dir, exist_ok=True) - with open(write_filepath, mode="wb") as f: - f.write(data) + os.makedirs(write_dir, exist_ok=True) + with open(write_filepath, mode="wb") as f: + f.write(data) async def download_mbtiles( client: httpx.AsyncClient, - semaphore: asyncio.Semaphore, + rate_limiter: RateLimiter, conn: sqlite3.Connection, tile: tiletanic.Tile, tileurl: str, @@ -70,41 +87,41 @@ async def download_mbtiles( overwrite: bool = False, tms: bool = False, ): - async with semaphore: - if tms: - ty = tile.y - else: - ty = (1 << tile.z) - 1 - tile.y + await rate_limiter.acquire() + if tms: + ty = tile.y + else: + ty = (1 << tile.z) - 1 - tile.y - c = conn.cursor() - c.execute( - "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", - (tile.z, tile.x, ty), - ) - if c.fetchone() is not None and not overwrite: - return - - url = ( - tileurl.replace(r"{x}", str(tile.x)) - .replace(r"{y}", str(tile.y)) - .replace(r"{z}", str(tile.z)) - ) + c = conn.cursor() + c.execute( + "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", + (tile.z, tile.x, ty), + ) + if c.fetchone() is not None and not overwrite: + return - data = await fetch_data(client, url, timeout) - if data is None: - return + url = ( + tileurl.replace(r"{x}", str(tile.x)) + .replace(r"{y}", str(tile.y)) + .replace(r"{z}", str(tile.z)) + ) - if overwrite: - c.execute( - "DELETE FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", - (tile.z, tile.x, ty), - ) + data = await fetch_data(client, url, timeout) + if data is None: + return + if overwrite: c.execute( - "INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", - (tile.z, tile.x, ty, data), + "DELETE FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", + (tile.z, tile.x, ty), ) - conn.commit() + + c.execute( + "INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", + (tile.z, tile.x, ty, data), + ) + conn.commit() def create_mbtiles(output_file: str): @@ -143,8 +160,7 @@ def create_mbtiles(output_file: str): async def run(): params = parse_arg() - concurrency = max(1, 1000 // params.interval) - semaphore = asyncio.Semaphore(concurrency) + rate_limiter = RateLimiter(max(1, params.rps)) conn = None if params.mode == "mbtiles": @@ -191,7 +207,7 @@ async def run(): tasks = [ download_dir( client, - semaphore, + rate_limiter, tile, params.tileurl, params.output_path, @@ -205,7 +221,7 @@ async def run(): tasks = [ download_mbtiles( client, - semaphore, + rate_limiter, conn, tile, params.tileurl, diff --git a/tileget/arg.py b/tileget/arg.py index a6e3a1d..17ea665 100644 --- a/tileget/arg.py +++ b/tileget/arg.py @@ -15,7 +15,7 @@ class RunParams: geometry: shapely.geometry.base.BaseGeometry minzoom: int = 0 maxzoom: int = 16 - interval: int = 1000 + rps: int = 2 overwrite: bool = False timeout: int = 5000 tms: bool = False @@ -38,10 +38,10 @@ def parse_arg() -> RunParams: parser.add_argument("--minzoom", default=0, type=int, help="default to 0") parser.add_argument("--maxzoom", default=16, type=int, help="default to 16") parser.add_argument( - "--interval", - default=500, + "--rps", + default=2, type=int, - help="time taken after each-request, set as miliseconds in interger, default to 500", + help="requests per second, default to 2", ) parser.add_argument( "--overwrite", help="overwrite existing files", action="store_true" @@ -105,7 +105,7 @@ def parse_arg() -> RunParams: geometry=geom_3857, minzoom=args.minzoom, maxzoom=args.maxzoom, - interval=args.interval, + rps=args.rps, overwrite=args.overwrite, timeout=args.timeout, tms=args.tms, From e3f7a47db60a2b5b8bc627de2138fb9b3d76a4b0 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:22:19 +0900 Subject: [PATCH 02/12] rps --- .gitignore | 3 ++- tileget/__main__.py | 6 +++--- tileget/arg.py | 14 ++++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 8ecd869..7452234 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ .DS_Store .venv dist -.ruff_cache \ No newline at end of file +.ruff_cache +*.mbtiles \ No newline at end of file diff --git a/tileget/__main__.py b/tileget/__main__.py index 576cc17..1202411 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -53,7 +53,6 @@ async def download_dir( timeout: int = 5000, overwrite: bool = False, ): - await rate_limiter.acquire() ext = os.path.splitext(tileurl.split("?")[0])[-1] write_dir = os.path.join(output_path, str(tile.z), str(tile.x)) @@ -62,6 +61,7 @@ async def download_dir( if os.path.exists(write_filepath) and not overwrite: return + await rate_limiter.acquire() url = ( tileurl.replace(r"{x}", str(tile.x)) .replace(r"{y}", str(tile.y)) @@ -87,7 +87,6 @@ async def download_mbtiles( overwrite: bool = False, tms: bool = False, ): - await rate_limiter.acquire() if tms: ty = tile.y else: @@ -101,6 +100,7 @@ async def download_mbtiles( if c.fetchone() is not None and not overwrite: return + await rate_limiter.acquire() url = ( tileurl.replace(r"{x}", str(tile.x)) .replace(r"{y}", str(tile.y)) @@ -160,7 +160,7 @@ def create_mbtiles(output_file: str): async def run(): params = parse_arg() - rate_limiter = RateLimiter(max(1, params.rps)) + rate_limiter = RateLimiter(params.rps) conn = None if params.mode == "mbtiles": diff --git a/tileget/arg.py b/tileget/arg.py index 17ea665..826ce00 100644 --- a/tileget/arg.py +++ b/tileget/arg.py @@ -15,7 +15,7 @@ class RunParams: geometry: shapely.geometry.base.BaseGeometry minzoom: int = 0 maxzoom: int = 16 - rps: int = 2 + rps: int = 1 overwrite: bool = False timeout: int = 5000 tms: bool = False @@ -37,11 +37,17 @@ def parse_arg() -> RunParams: ) parser.add_argument("--minzoom", default=0, type=int, help="default to 0") parser.add_argument("--maxzoom", default=16, type=int, help="default to 16") + def positive_int(value: str) -> int: + ivalue = int(value) + if ivalue <= 0: + raise argparse.ArgumentTypeError("must be a positive integer") + return ivalue + parser.add_argument( "--rps", - default=2, - type=int, - help="requests per second, default to 2", + default=1, + type=positive_int, + help="requests per second, must be positive, default to 1", ) parser.add_argument( "--overwrite", help="overwrite existing files", action="store_true" From 9cc6d1bfa88afd950eded5e0a20e6d5ffc4a85c1 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:24:49 +0900 Subject: [PATCH 03/12] fix: stop cosuming generator at once --- tileget/__main__.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index 1202411..ecf26e3 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -199,13 +199,13 @@ async def run(): async with httpx.AsyncClient() as client: for zoom in range(params.minzoom, params.maxzoom + 1): - tiles = list( - tiletanic.tilecover.cover_geometry(tilescheme, params.geometry, zoom) + tiles = tiletanic.tilecover.cover_geometry( + tilescheme, params.geometry, zoom ) - if params.mode == "dir": - tasks = [ - download_dir( + for tile in tiles: + if params.mode == "dir": + await download_dir( client, rate_limiter, tile, @@ -214,12 +214,9 @@ async def run(): params.timeout, params.overwrite, ) - for tile in tiles - ] - else: - assert conn is not None - tasks = [ - download_mbtiles( + else: + assert conn is not None + await download_mbtiles( client, rate_limiter, conn, @@ -229,10 +226,6 @@ async def run(): params.overwrite, params.tms, ) - for tile in tiles - ] - - await asyncio.gather(*tasks) if conn is not None: conn.close() From 1a2b8eae04863d9157f78f53e52c03f6a62a5e29 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:28:09 +0900 Subject: [PATCH 04/12] retry with exponential backoff --- tileget/__main__.py | 46 ++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index ecf26e3..a0f26fd 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -1,5 +1,6 @@ import asyncio import os +import random import sqlite3 import time @@ -8,6 +9,9 @@ from tileget.arg import parse_arg +MAX_RETRIES = 3 +BASE_DELAY = 1.0 + class RateLimiter: def __init__(self, rps: int): @@ -25,23 +29,39 @@ async def acquire(self): self.last_request_time = time.monotonic() +def is_retryable_error(e: Exception) -> bool: + if isinstance(e, httpx.TimeoutException): + return True + if isinstance(e, httpx.HTTPStatusError): + return e.response.status_code >= 500 or e.response.status_code == 429 + return False + + async def fetch_data( client: httpx.AsyncClient, url: str, timeout: int = 5000 ) -> bytes | None: print("downloading: " + url) - try: - response = await client.get(url, timeout=timeout / 1000) - response.raise_for_status() - return response.content - except httpx.HTTPStatusError as e: - print(f"{e.response.status_code}: {url}") - return None - except httpx.TimeoutException: - print(f"timeout: {url}") - return None - except Exception as e: - print(f"{e}: {url}") - return None + + for attempt in range(MAX_RETRIES + 1): + try: + response = await client.get(url, timeout=timeout / 1000) + response.raise_for_status() + return response.content + except Exception as e: + if not is_retryable_error(e) or attempt == MAX_RETRIES: + if isinstance(e, httpx.HTTPStatusError): + print(f"{e.response.status_code}: {url}") + elif isinstance(e, httpx.TimeoutException): + print(f"timeout: {url}") + else: + print(f"{e}: {url}") + return None + + delay = BASE_DELAY * (2**attempt) + random.uniform(0, 1) + print(f"retry {attempt + 1}/{MAX_RETRIES} after {delay:.1f}s: {url}") + await asyncio.sleep(delay) + + return None async def download_dir( From dcec59cfc17f9c32869f6284417eefbe4d19c20a Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:30:59 +0900 Subject: [PATCH 05/12] feat: retries and retry-delay as args --- tileget/__main__.py | 29 +++++++++++++++++++---------- tileget/arg.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index a0f26fd..bd77b27 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -9,9 +9,6 @@ from tileget.arg import parse_arg -MAX_RETRIES = 3 -BASE_DELAY = 1.0 - class RateLimiter: def __init__(self, rps: int): @@ -38,17 +35,21 @@ def is_retryable_error(e: Exception) -> bool: async def fetch_data( - client: httpx.AsyncClient, url: str, timeout: int = 5000 + client: httpx.AsyncClient, + url: str, + timeout: int = 5000, + retries: int = 3, + retry_delay: float = 1.0, ) -> bytes | None: print("downloading: " + url) - for attempt in range(MAX_RETRIES + 1): + for attempt in range(retries + 1): try: response = await client.get(url, timeout=timeout / 1000) response.raise_for_status() return response.content except Exception as e: - if not is_retryable_error(e) or attempt == MAX_RETRIES: + if not is_retryable_error(e) or attempt == retries: if isinstance(e, httpx.HTTPStatusError): print(f"{e.response.status_code}: {url}") elif isinstance(e, httpx.TimeoutException): @@ -57,8 +58,8 @@ async def fetch_data( print(f"{e}: {url}") return None - delay = BASE_DELAY * (2**attempt) + random.uniform(0, 1) - print(f"retry {attempt + 1}/{MAX_RETRIES} after {delay:.1f}s: {url}") + delay = retry_delay * (2**attempt) + random.uniform(0, 1) + print(f"retry {attempt + 1}/{retries} after {delay:.1f}s: {url}") await asyncio.sleep(delay) return None @@ -72,6 +73,8 @@ async def download_dir( output_path: str, timeout: int = 5000, overwrite: bool = False, + retries: int = 3, + retry_delay: float = 1.0, ): ext = os.path.splitext(tileurl.split("?")[0])[-1] @@ -88,7 +91,7 @@ async def download_dir( .replace(r"{z}", str(tile.z)) ) - data = await fetch_data(client, url, timeout) + data = await fetch_data(client, url, timeout, retries, retry_delay) if data is None: return @@ -106,6 +109,8 @@ async def download_mbtiles( timeout: int = 5000, overwrite: bool = False, tms: bool = False, + retries: int = 3, + retry_delay: float = 1.0, ): if tms: ty = tile.y @@ -127,7 +132,7 @@ async def download_mbtiles( .replace(r"{z}", str(tile.z)) ) - data = await fetch_data(client, url, timeout) + data = await fetch_data(client, url, timeout, retries, retry_delay) if data is None: return @@ -233,6 +238,8 @@ async def run(): params.output_path, params.timeout, params.overwrite, + params.retries, + params.retry_delay, ) else: assert conn is not None @@ -245,6 +252,8 @@ async def run(): params.timeout, params.overwrite, params.tms, + params.retries, + params.retry_delay, ) if conn is not None: diff --git a/tileget/arg.py b/tileget/arg.py index 826ce00..7e5708c 100644 --- a/tileget/arg.py +++ b/tileget/arg.py @@ -19,6 +19,8 @@ class RunParams: overwrite: bool = False timeout: int = 5000 tms: bool = False + retries: int = 3 + retry_delay: float = 1.0 def parse_arg() -> RunParams: @@ -59,6 +61,18 @@ def positive_int(value: str) -> int: help="wait response until this value, set as miliseconds in integer, default to 5000", ) parser.add_argument("--tms", help="if set, parse z/x/y as TMS", action="store_true") + parser.add_argument( + "--retries", + default=3, + type=int, + help="max retry count on error, default to 3", + ) + parser.add_argument( + "--retry-delay", + default=1.0, + type=float, + help="base delay in seconds for exponential backoff, default to 1.0", + ) args = parser.parse_args() if args.output_dir is None and args.output_file is None: @@ -115,6 +129,8 @@ def positive_int(value: str) -> int: overwrite=args.overwrite, timeout=args.timeout, tms=args.tms, + retries=args.retries, + retry_delay=args.retry_delay, ) return params From 6eab5f55874278c844c7e8e501e333f66c88264c Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:33:38 +0900 Subject: [PATCH 06/12] refactor: avoid optional params --- pyproject.toml | 6 ++++- tileget/__main__.py | 24 ++++++++++---------- tileget/arg.py | 16 +++++++------- uv.lock | 54 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5a9315a..7fa8bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,11 @@ dependencies = [ ] [dependency-groups] -dev = ["ruff>=0.3.7", "ty>=0.0.13"] +dev = [ + "pytest>=9.0.2", + "ruff>=0.3.7", + "ty>=0.0.13", +] [project.scripts] tileget = "tileget.__main__:main" diff --git a/tileget/__main__.py b/tileget/__main__.py index bd77b27..ebcbab6 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -37,9 +37,9 @@ def is_retryable_error(e: Exception) -> bool: async def fetch_data( client: httpx.AsyncClient, url: str, - timeout: int = 5000, - retries: int = 3, - retry_delay: float = 1.0, + timeout: int, + retries: int, + retry_delay: float, ) -> bytes | None: print("downloading: " + url) @@ -71,10 +71,10 @@ async def download_dir( tile: tiletanic.Tile, tileurl: str, output_path: str, - timeout: int = 5000, - overwrite: bool = False, - retries: int = 3, - retry_delay: float = 1.0, + timeout: int, + overwrite: bool, + retries: int, + retry_delay: float, ): ext = os.path.splitext(tileurl.split("?")[0])[-1] @@ -106,11 +106,11 @@ async def download_mbtiles( conn: sqlite3.Connection, tile: tiletanic.Tile, tileurl: str, - timeout: int = 5000, - overwrite: bool = False, - tms: bool = False, - retries: int = 3, - retry_delay: float = 1.0, + timeout: int, + overwrite: bool, + tms: bool, + retries: int, + retry_delay: float, ): if tms: ty = tile.y diff --git a/tileget/arg.py b/tileget/arg.py index 7e5708c..b272e0c 100644 --- a/tileget/arg.py +++ b/tileget/arg.py @@ -13,14 +13,14 @@ class RunParams: mode: Literal["dir", "mbtiles"] output_path: str geometry: shapely.geometry.base.BaseGeometry - minzoom: int = 0 - maxzoom: int = 16 - rps: int = 1 - overwrite: bool = False - timeout: int = 5000 - tms: bool = False - retries: int = 3 - retry_delay: float = 1.0 + minzoom: int + maxzoom: int + rps: int + overwrite: bool + timeout: int + tms: bool + retries: int + retry_delay: float def parse_arg() -> RunParams: diff --git a/uv.lock b/uv.lock index baa628e..6b8e22d 100644 --- a/uv.lock +++ b/uv.lock @@ -99,6 +99,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "numpy" version = "2.4.1" @@ -128,6 +137,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyproj" version = "3.7.2" @@ -157,6 +193,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "ruff" version = "0.14.14" @@ -223,6 +275,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] @@ -237,6 +290,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pytest", specifier = ">=9.0.2" }, { name = "ruff", specifier = ">=0.3.7" }, { name = "ty", specifier = ">=0.0.13" }, ] From 2daf9783ce9620b48fa7e55d4288ad5ed8732327 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:39:12 +0900 Subject: [PATCH 07/12] fix: use taskgroup --- tileget/__main__.py | 59 ++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index ebcbab6..b51d5b6 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -228,33 +228,38 @@ async def run(): tilescheme, params.geometry, zoom ) - for tile in tiles: - if params.mode == "dir": - await download_dir( - client, - rate_limiter, - tile, - params.tileurl, - params.output_path, - params.timeout, - params.overwrite, - params.retries, - params.retry_delay, - ) - else: - assert conn is not None - await download_mbtiles( - client, - rate_limiter, - conn, - tile, - params.tileurl, - params.timeout, - params.overwrite, - params.tms, - params.retries, - params.retry_delay, - ) + async with asyncio.TaskGroup() as tg: + for tile in tiles: + if params.mode == "dir": + tg.create_task( + download_dir( + client, + rate_limiter, + tile, + params.tileurl, + params.output_path, + params.timeout, + params.overwrite, + params.retries, + params.retry_delay, + ) + ) + else: + assert conn is not None + tg.create_task( + download_mbtiles( + client, + rate_limiter, + conn, + tile, + params.tileurl, + params.timeout, + params.overwrite, + params.tms, + params.retries, + params.retry_delay, + ) + ) if conn is not None: conn.close() From 019f472a8a50da94a4ea7c5c79708d74dc06ef6a Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 21:49:15 +0900 Subject: [PATCH 08/12] feat: show tiles/s --- tileget/__main__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index b51d5b6..562d02a 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -9,6 +9,10 @@ from tileget.arg import parse_arg +# ダウンロード速度を計測するためのグローバル変数 +downloaded_count = 0 +start_time = 0.0 + class RateLimiter: def __init__(self, rps: int): @@ -41,12 +45,16 @@ async def fetch_data( retries: int, retry_delay: float, ) -> bytes | None: - print("downloading: " + url) + global downloaded_count for attempt in range(retries + 1): try: response = await client.get(url, timeout=timeout / 1000) response.raise_for_status() + downloaded_count += 1 + elapsed = time.monotonic() - start_time + speed = downloaded_count / elapsed if elapsed > 0 else 0 + print(f"{downloaded_count} tiles ({speed:.1f} tiles/s): {url}") return response.content except Exception as e: if not is_retryable_error(e) or attempt == retries: @@ -183,7 +191,9 @@ def create_mbtiles(output_file: str): async def run(): + global start_time params = parse_arg() + start_time = time.monotonic() rate_limiter = RateLimiter(params.rps) From cec404884dfccc24b080ecb5482ef655bf4a9fbb Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 22:01:48 +0900 Subject: [PATCH 09/12] gracefulshutdown --- tileget/__main__.py | 120 +++++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 35 deletions(-) diff --git a/tileget/__main__.py b/tileget/__main__.py index 562d02a..d054786 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -1,6 +1,7 @@ import asyncio import os import random +import signal import sqlite3 import time @@ -13,6 +14,9 @@ downloaded_count = 0 start_time = 0.0 +# グレースフルシャットダウン用フラグ +shutdown_requested = False + class RateLimiter: def __init__(self, rps: int): @@ -21,13 +25,24 @@ def __init__(self, rps: int): self.last_request_time = 0.0 self.lock = asyncio.Lock() - async def acquire(self): + async def acquire(self) -> bool: + """レートリミットを取得。シャットダウン時はFalseを返す""" + if shutdown_requested: + return False + async with self.lock: + if shutdown_requested: + return False + now = time.monotonic() wait_time = self.last_request_time + self.interval - now if wait_time > 0: await asyncio.sleep(wait_time) + if shutdown_requested: + return False + self.last_request_time = time.monotonic() + return True def is_retryable_error(e: Exception) -> bool: @@ -69,6 +84,8 @@ async def fetch_data( delay = retry_delay * (2**attempt) + random.uniform(0, 1) print(f"retry {attempt + 1}/{retries} after {delay:.1f}s: {url}") await asyncio.sleep(delay) + if shutdown_requested: + return None return None @@ -92,7 +109,9 @@ async def download_dir( if os.path.exists(write_filepath) and not overwrite: return - await rate_limiter.acquire() + if not await rate_limiter.acquire(): + return + url = ( tileurl.replace(r"{x}", str(tile.x)) .replace(r"{y}", str(tile.y)) @@ -133,7 +152,9 @@ async def download_mbtiles( if c.fetchone() is not None and not overwrite: return - await rate_limiter.acquire() + if not await rate_limiter.acquire(): + return + url = ( tileurl.replace(r"{x}", str(tile.x)) .replace(r"{y}", str(tile.y)) @@ -191,10 +212,22 @@ def create_mbtiles(output_file: str): async def run(): - global start_time + global start_time, shutdown_requested params = parse_arg() start_time = time.monotonic() + # SIGINTハンドラを設定 + loop = asyncio.get_running_loop() + + def handle_sigint(): + global shutdown_requested + if not shutdown_requested: + shutdown_requested = True + print("\nShutdown requested. Waiting for running tasks to complete...") + + loop.add_signal_handler(signal.SIGINT, handle_sigint) + loop.add_signal_handler(signal.SIGTERM, handle_sigint) + rate_limiter = RateLimiter(params.rps) conn = None @@ -234,47 +267,64 @@ async def run(): async with httpx.AsyncClient() as client: for zoom in range(params.minzoom, params.maxzoom + 1): + if shutdown_requested: + break + tiles = tiletanic.tilecover.cover_geometry( tilescheme, params.geometry, zoom ) - async with asyncio.TaskGroup() as tg: - for tile in tiles: - if params.mode == "dir": - tg.create_task( - download_dir( - client, - rate_limiter, - tile, - params.tileurl, - params.output_path, - params.timeout, - params.overwrite, - params.retries, - params.retry_delay, - ) + # TaskGroupの代わりに手動でタスクを管理 + pending_tasks: set[asyncio.Task] = set() + + for tile in tiles: + if shutdown_requested: + break + + if params.mode == "dir": + task = asyncio.create_task( + download_dir( + client, + rate_limiter, + tile, + params.tileurl, + params.output_path, + params.timeout, + params.overwrite, + params.retries, + params.retry_delay, ) - else: - assert conn is not None - tg.create_task( - download_mbtiles( - client, - rate_limiter, - conn, - tile, - params.tileurl, - params.timeout, - params.overwrite, - params.tms, - params.retries, - params.retry_delay, - ) + ) + else: + assert conn is not None + task = asyncio.create_task( + download_mbtiles( + client, + rate_limiter, + conn, + tile, + params.tileurl, + params.timeout, + params.overwrite, + params.tms, + params.retries, + params.retry_delay, ) + ) + pending_tasks.add(task) + task.add_done_callback(pending_tasks.discard) + + # 残っているタスクの完了を待つ + if pending_tasks: + await asyncio.gather(*pending_tasks, return_exceptions=True) if conn is not None: conn.close() - print("finished") + if shutdown_requested: + print("Shutdown complete.") + else: + print("finished") def main(): From 983625f9721764b897c159aa4da77087459d11fb Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 22:08:29 +0900 Subject: [PATCH 10/12] fix: timeout as seconds --- README.md | 37 ++++++++++++++++++++++++------------- tileget/__main__.py | 8 ++++---- tileget/arg.py | 8 ++++---- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 11e776d..4e21c67 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@ # tileget -![GitHub Release](https://img.shields.io/github/v/release/Kanahiro/tileget?label=PyPI) +![GitHub Release](https://img.shields.io/github/v/release/Kanahiro/tileget) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Kanahiro/tileget/lint.yml?label=lint) Tile download utility - easily download xyz-tile data. -## installation +## Requirements + +- Python >= 3.14 + +## Installation ```sh pip install tileget +# or +uv pip install tileget ``` -## usage +## Usage -```planetext -usage: __main__.py [-h] [-e OUTPUT_DIR] [-o OUTPUT_FILE] [--extent EXTENT EXTENT EXTENT EXTENT] [--geojson GEOJSON] [--minzoom MINZOOM] [--maxzoom MAXZOOM] [--interval INTERVAL] [--overwrite] [--timeout TIMEOUT] [--tms] - tileurl +```plaintext +usage: tileget [-h] [-e OUTPUT_DIR] [-o OUTPUT_FILE] [--extent EXTENT EXTENT EXTENT EXTENT] + [--geojson GEOJSON] [--minzoom MINZOOM] [--maxzoom MAXZOOM] [--rps RPS] [--overwrite] + [--timeout TIMEOUT] [--tms] [--retries RETRIES] [--retry-delay RETRY_DELAY] + tileurl xyz-tile download tool @@ -24,28 +32,31 @@ positional arguments: options: -h, --help show this help message and exit - -e OUTPUT_DIR, --output_dir OUTPUT_DIR + -e, --output_dir OUTPUT_DIR output dir - -o OUTPUT_FILE, --output_file OUTPUT_FILE + -o, --output_file OUTPUT_FILE output mbtiles file --extent EXTENT EXTENT EXTENT EXTENT min_lon min_lat max_lon max_lat, whitespace delimited --geojson GEOJSON path to geojson file of Feature or FeatureCollection --minzoom MINZOOM default to 0 --maxzoom MAXZOOM default to 16 - --interval INTERVAL time taken after each-request, set as miliseconds in interger, default to 500 + --rps RPS requests per second, must be positive, default to 1 --overwrite overwrite existing files - --timeout TIMEOUT wait response until this value, set as miliseconds in integer, default to 5000 + --timeout TIMEOUT wait response until this value in seconds, default to 5.0 --tms if set, parse z/x/y as TMS + --retries RETRIES max retry count on error, default to 3 + --retry-delay RETRY_DELAY + base delay in seconds for exponential backoff, default to 1.0 ``` -### examples +### Examples ```sh # basic usage tileget http://path/to/tile/{z}/{x}/{y}.jpg -e output_dir --extent 141.23 40.56 142.45 43.78 tileget http://path/to/tile/{z}/{x}/{y}.jpg -o output.mbtiles --geojson input.geojson -# optional arguments -tileget http://path/to/tile/{z}/{x}/{y}.jpg -e output_dir --extent 141.23 40.56 142.45 43.78 --minzoom 0 --maxzoom 16 --interval 500 --timeout 5000 --overwrite +# with rate limiting and retry options +tileget http://path/to/tile/{z}/{x}/{y}.jpg -e output_dir --extent 141.23 40.56 142.45 43.78 --minzoom 0 --maxzoom 16 --rps 5 --retries 5 --retry-delay 2.0 --timeout 10 --overwrite ``` diff --git a/tileget/__main__.py b/tileget/__main__.py index d054786..6ce1a2f 100644 --- a/tileget/__main__.py +++ b/tileget/__main__.py @@ -56,7 +56,7 @@ def is_retryable_error(e: Exception) -> bool: async def fetch_data( client: httpx.AsyncClient, url: str, - timeout: int, + timeout: float, retries: int, retry_delay: float, ) -> bytes | None: @@ -64,7 +64,7 @@ async def fetch_data( for attempt in range(retries + 1): try: - response = await client.get(url, timeout=timeout / 1000) + response = await client.get(url, timeout=timeout) response.raise_for_status() downloaded_count += 1 elapsed = time.monotonic() - start_time @@ -96,7 +96,7 @@ async def download_dir( tile: tiletanic.Tile, tileurl: str, output_path: str, - timeout: int, + timeout: float, overwrite: bool, retries: int, retry_delay: float, @@ -133,7 +133,7 @@ async def download_mbtiles( conn: sqlite3.Connection, tile: tiletanic.Tile, tileurl: str, - timeout: int, + timeout: float, overwrite: bool, tms: bool, retries: int, diff --git a/tileget/arg.py b/tileget/arg.py index b272e0c..02212a2 100644 --- a/tileget/arg.py +++ b/tileget/arg.py @@ -17,7 +17,7 @@ class RunParams: maxzoom: int rps: int overwrite: bool - timeout: int + timeout: float tms: bool retries: int retry_delay: float @@ -56,9 +56,9 @@ def positive_int(value: str) -> int: ) parser.add_argument( "--timeout", - default=5000, - type=int, - help="wait response until this value, set as miliseconds in integer, default to 5000", + default=5.0, + type=float, + help="wait response until this value in seconds, default to 5.0", ) parser.add_argument("--tms", help="if set, parse z/x/y as TMS", action="store_true") parser.add_argument( From 7d2bf7b3222ee336ea50105af7e6ea7f04dd2c81 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 22:09:17 +0900 Subject: [PATCH 11/12] rmtest --- pyproject.toml | 8 ++------ uv.lock | 56 +------------------------------------------------- 2 files changed, 3 insertions(+), 61 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7fa8bde..e61f2a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tileget" -version = "0.4.3" +version = "1.0.0" description = "Tile download utility - easily download xyz-tile data" readme = "README.md" requires-python = ">= 3.14" @@ -12,11 +12,7 @@ dependencies = [ ] [dependency-groups] -dev = [ - "pytest>=9.0.2", - "ruff>=0.3.7", - "ty>=0.0.13", -] +dev = ["ruff>=0.3.7", "ty>=0.0.13"] [project.scripts] tileget = "tileget.__main__:main" diff --git a/uv.lock b/uv.lock index 6b8e22d..633a92b 100644 --- a/uv.lock +++ b/uv.lock @@ -99,15 +99,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "numpy" version = "2.4.1" @@ -137,33 +128,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - [[package]] name = "pyproj" version = "3.7.2" @@ -193,22 +157,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - [[package]] name = "ruff" version = "0.14.14" @@ -264,7 +212,7 @@ wheels = [ [[package]] name = "tileget" -version = "0.4.3" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -275,7 +223,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] @@ -290,7 +237,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, { name = "ruff", specifier = ">=0.3.7" }, { name = "ty", specifier = ">=0.0.13" }, ] From 04998791e3f8f9ca4295ff632e03d1dc5688bd21 Mon Sep 17 00:00:00 2001 From: Kanahiro Date: Sun, 25 Jan 2026 22:10:07 +0900 Subject: [PATCH 12/12] fix --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 4e21c67..c242dd6 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ Tile download utility - easily download xyz-tile data. ```sh pip install tileget -# or -uv pip install tileget ``` ## Usage