vectortileserver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vectortileserver/__init__.py +8 -0
- vectortileserver/app.py +32 -0
- vectortileserver/client.py +212 -0
- vectortileserver/converter.py +134 -0
- vectortileserver/endpoints.py +132 -0
- vectortileserver/feature_query.py +373 -0
- vectortileserver/handler.py +48 -0
- vectortileserver/logger.py +60 -0
- vectortileserver/pmtiles_layer.py +22 -0
- vectortileserver/server.py +181 -0
- vectortileserver/styles.py +199 -0
- vectortileserver/utils.py +39 -0
- vectortileserver-0.1.0.dist-info/METADATA +52 -0
- vectortileserver-0.1.0.dist-info/RECORD +16 -0
- vectortileserver-0.1.0.dist-info/WHEEL +4 -0
- vectortileserver-0.1.0.dist-info/licenses/LICENSE +21 -0
vectortileserver/app.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from starlette.applications import Starlette
|
|
2
|
+
from starlette.middleware import Middleware
|
|
3
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
4
|
+
from starlette.routing import Route
|
|
5
|
+
|
|
6
|
+
from vectortileserver.endpoints import pmtiles_endpoint, shutdown_endpoint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_app(tile_server_instance):
|
|
10
|
+
"""Create the Starlette application with routes and middleware."""
|
|
11
|
+
|
|
12
|
+
async def shutdown_wrapper(request):
|
|
13
|
+
return await shutdown_endpoint(request, tile_server_instance)
|
|
14
|
+
|
|
15
|
+
async def pmtiles_wrapper(request):
|
|
16
|
+
return await pmtiles_endpoint(request, tile_server_instance)
|
|
17
|
+
|
|
18
|
+
routes = [
|
|
19
|
+
Route("/shutdown", shutdown_wrapper),
|
|
20
|
+
Route("/pmtiles", pmtiles_wrapper),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
middleware = [
|
|
24
|
+
Middleware(
|
|
25
|
+
CORSMiddleware,
|
|
26
|
+
allow_origins=tile_server_instance.config.cors_origins,
|
|
27
|
+
allow_methods=["GET"],
|
|
28
|
+
allow_headers=["*"],
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
return Starlette(debug=tile_server_instance.config.debug, routes=routes, middleware=middleware)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from vectortileserver.handler import get_metadata
|
|
9
|
+
from vectortileserver.logger import logger
|
|
10
|
+
from vectortileserver.styles import generate_default_map_style
|
|
11
|
+
|
|
12
|
+
from .converter import TileConverter
|
|
13
|
+
from .server import TileServer
|
|
14
|
+
from .utils import is_port_in_use
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TileClient:
|
|
18
|
+
"""
|
|
19
|
+
A client for accessing PMTiles from a local server.
|
|
20
|
+
|
|
21
|
+
This class triggers the lazy initialization of the server if needed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
data_source: Union[str, Path] | None = None,
|
|
27
|
+
host: str = "localhost",
|
|
28
|
+
port: Optional[int] = None,
|
|
29
|
+
converter: Optional[TileConverter] = None,
|
|
30
|
+
conversion_options: Dict[str, Any] | None = None,
|
|
31
|
+
allowed_directories: List[Union[str, Path]] | None = None,
|
|
32
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the tile client.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
data_source: Path to the vector data source (GeoJSON, Shapefile, PMTiles, etc.)
|
|
39
|
+
host: Host where the server is running.
|
|
40
|
+
port: Port where the server is running.
|
|
41
|
+
converter: Custom TileConverter instance to use.
|
|
42
|
+
conversion_options: Options to pass to the tile converter.
|
|
43
|
+
allowed_directories: List of directories that can be accessed by the server.
|
|
44
|
+
http_client: Custom HTTP client for testing.
|
|
45
|
+
"""
|
|
46
|
+
logger.debug(f"Initializing tile client with data source: {data_source}")
|
|
47
|
+
|
|
48
|
+
self.data_source = Path(data_source) if data_source else None
|
|
49
|
+
self.host = host
|
|
50
|
+
self.port = port
|
|
51
|
+
self.converter = converter
|
|
52
|
+
self.conversion_options = conversion_options or {}
|
|
53
|
+
self.pmtiles_path = None
|
|
54
|
+
self.allowed_directories = allowed_directories
|
|
55
|
+
self._http_client = http_client
|
|
56
|
+
|
|
57
|
+
self.pmtiles_directory = self.data_source.parent
|
|
58
|
+
self.metadata = None
|
|
59
|
+
|
|
60
|
+
self._process_data_source()
|
|
61
|
+
|
|
62
|
+
if self.pmtiles_path is None:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"PMTiles file is not available. Ensure that a valid data_source is "
|
|
65
|
+
"provided or that the pmtiles_directory contains a PMTiles file."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Ensure the server is running
|
|
69
|
+
self._ensure_server_running()
|
|
70
|
+
|
|
71
|
+
# Store the server URL
|
|
72
|
+
self.server_url = f"http://{self.host}:{self.port}"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def pmtiles_url(self) -> str:
|
|
76
|
+
"""Get the URL for the PMTiles file with its filePath."""
|
|
77
|
+
return f"{self.server_url}/pmtiles?filePath={self.pmtiles_path}"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def bounds(self) -> List:
|
|
81
|
+
"""Get the bounds of the PMTiles file."""
|
|
82
|
+
return self.metadata.get("bounds", [])
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def center(self) -> Tuple:
|
|
86
|
+
"""Get the center of the PMTiles file."""
|
|
87
|
+
return self.metadata.get("center", [])
|
|
88
|
+
|
|
89
|
+
def get_metadata(self) -> Dict[str, Any]:
|
|
90
|
+
"""Get metadata for the PMTiles file."""
|
|
91
|
+
return get_metadata(self.pmtiles_path)
|
|
92
|
+
|
|
93
|
+
def list_layers(self):
|
|
94
|
+
"""Return a list of available vector layer IDs from the metadata."""
|
|
95
|
+
return [layer.get("id") for layer in self.metadata.get("vector_layers", [])]
|
|
96
|
+
|
|
97
|
+
def _process_data_source(self) -> None:
|
|
98
|
+
"""Process the data source file."""
|
|
99
|
+
|
|
100
|
+
if self.data_source.suffix.lower() == ".pmtiles":
|
|
101
|
+
self._handle_pmtiles()
|
|
102
|
+
|
|
103
|
+
elif existing_pmtiles := self._find_pmtiles_files(self.pmtiles_directory):
|
|
104
|
+
if self.data_source.with_suffix(".pmtiles") in existing_pmtiles:
|
|
105
|
+
self.pmtiles_path = self.data_source.with_suffix(".pmtiles")
|
|
106
|
+
logger.debug(f"Found PMTiles file in provided directory: {self.pmtiles_path}")
|
|
107
|
+
else:
|
|
108
|
+
self._convert_vector_data()
|
|
109
|
+
|
|
110
|
+
self.metadata = self.get_metadata()
|
|
111
|
+
|
|
112
|
+
def _handle_pmtiles(self) -> None:
|
|
113
|
+
"""Handle a PMTiles file directly without conversion.
|
|
114
|
+
|
|
115
|
+
If the metadata file is not present in the same folder as the PMTiles file,
|
|
116
|
+
create it.
|
|
117
|
+
"""
|
|
118
|
+
logger.debug(f"Using PMTiles file directly: {self.data_source}")
|
|
119
|
+
|
|
120
|
+
if not self.data_source.exists():
|
|
121
|
+
raise FileNotFoundError(f"PMTiles file not found: {self.data_source}")
|
|
122
|
+
|
|
123
|
+
dest_file = self.pmtiles_directory / self.data_source.name
|
|
124
|
+
if not dest_file.exists() or dest_file.stat().st_mtime < self.data_source.stat().st_mtime:
|
|
125
|
+
logger.debug(f"Copying PMTiles file to {dest_file}")
|
|
126
|
+
shutil.copy2(self.data_source, dest_file)
|
|
127
|
+
|
|
128
|
+
self.pmtiles_path = dest_file
|
|
129
|
+
|
|
130
|
+
def _convert_vector_data(self) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Process vector data formats and convert to PMTiles if needed.
|
|
133
|
+
"""
|
|
134
|
+
logger.debug(f"Processing vector data: {self.data_source} -> {self.pmtiles_directory}")
|
|
135
|
+
|
|
136
|
+
# Use provided converter or create a new one
|
|
137
|
+
converter = self.converter
|
|
138
|
+
if not converter:
|
|
139
|
+
converter = TileConverter(self.data_source, self.pmtiles_directory)
|
|
140
|
+
|
|
141
|
+
# Convert the data
|
|
142
|
+
if pmtiles_path := converter.convert(**self.conversion_options):
|
|
143
|
+
logger.debug(f"Converted data to PMTiles: {self.pmtiles_path}")
|
|
144
|
+
self.pmtiles_path = pmtiles_path
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
raise RuntimeError(f"No PMTiles file was generated in {self.pmtiles_directory}")
|
|
148
|
+
|
|
149
|
+
def _ensure_server_running(self) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Ensure the tile server is running.
|
|
152
|
+
"""
|
|
153
|
+
if self.port is not None and is_port_in_use(self.port):
|
|
154
|
+
logger.debug(f"Using existing server at port {self.port}")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
server = TileServer.get_instance(
|
|
158
|
+
host=self.host,
|
|
159
|
+
port=self.port,
|
|
160
|
+
allowed_directories=[self.pmtiles_directory] + (self.allowed_directories or []),
|
|
161
|
+
)
|
|
162
|
+
self.port = server.config.port
|
|
163
|
+
|
|
164
|
+
def create_leaflet_layer(
|
|
165
|
+
self,
|
|
166
|
+
style: Optional[Dict[str, Any]] = None,
|
|
167
|
+
layers_to_show: Optional[List[str]] = None,
|
|
168
|
+
) -> Any:
|
|
169
|
+
"""Create a PMTiles layer for ipyleaflet.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
style: Optional custom style for the layer
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
from vectortileserver.pmtiles_layer import LeafletPMTilesLayer
|
|
177
|
+
|
|
178
|
+
style_json = generate_default_map_style(self.metadata, self.pmtiles_url)
|
|
179
|
+
|
|
180
|
+
logger.debug(f"Generated style JSON: {json.dumps(style_json, indent=2)}")
|
|
181
|
+
|
|
182
|
+
if layers_to_show:
|
|
183
|
+
if not all(layer in self.list_layers() for layer in layers_to_show):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Invalid layer IDs provided. Available layers: {self.list_layers()}"
|
|
186
|
+
)
|
|
187
|
+
style_json["layers"] = [
|
|
188
|
+
layer
|
|
189
|
+
for layer in style_json["layers"]
|
|
190
|
+
if layer["source-layer"] in layers_to_show
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
logger.debug(f"Filtered style JSON: {json.dumps(style_json, indent=2)}")
|
|
194
|
+
|
|
195
|
+
return LeafletPMTilesLayer(
|
|
196
|
+
url=self.pmtiles_url,
|
|
197
|
+
style=style or style_json,
|
|
198
|
+
attribution="Vector Tile Server",
|
|
199
|
+
visible=True,
|
|
200
|
+
)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
raise e
|
|
203
|
+
raise ImportError(
|
|
204
|
+
"ipyleaflet is required to create a leaflet layer. "
|
|
205
|
+
"Install it with 'pip install ipyleaflet'."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _find_pmtiles_files(directory: Path) -> List[Path]:
|
|
210
|
+
"""Find PMTiles files in a directory."""
|
|
211
|
+
|
|
212
|
+
return list(directory.glob("*.pmtiles"))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Converter component for the Vector Tile Server package.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to convert vector data to PMTiles format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
import geopandas as gpd
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TileConverter:
|
|
19
|
+
"""
|
|
20
|
+
Converts vector data to PMTiles format using Tippecanoe or other methods.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
input_path: Union[str, Path],
|
|
26
|
+
output_path: Union[str, Path] | None = None,
|
|
27
|
+
tippecanoe_path: str = "tippecanoe",
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize the tile converter.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
input_path: Path to the input vector data
|
|
34
|
+
output_path: Path to write the output tiles
|
|
35
|
+
tippecanoe_path: Path to the tippecanoe executable
|
|
36
|
+
"""
|
|
37
|
+
self.input_path = Path(input_path)
|
|
38
|
+
self.output_path = (
|
|
39
|
+
Path(output_path) if output_path else self.input_path.with_suffix(".tiles")
|
|
40
|
+
)
|
|
41
|
+
self.tippecanoe_path = tippecanoe_path
|
|
42
|
+
|
|
43
|
+
# Validate input file existence
|
|
44
|
+
if not self.input_path.exists():
|
|
45
|
+
raise FileNotFoundError(f"Input file not found: {self.input_path}")
|
|
46
|
+
|
|
47
|
+
def convert(
|
|
48
|
+
self,
|
|
49
|
+
method: str = "auto",
|
|
50
|
+
max_zoom: int = 14,
|
|
51
|
+
min_zoom: int = 0,
|
|
52
|
+
**kwargs,
|
|
53
|
+
) -> Path:
|
|
54
|
+
"""
|
|
55
|
+
Convert the input data to PMTiles.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
method: Conversion method ('tippecanoe', 'python', or 'auto')
|
|
59
|
+
max_zoom: Maximum zoom level
|
|
60
|
+
min_zoom: Minimum zoom level
|
|
61
|
+
**kwargs: Additional options for the converter
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Path: Path to the output PMTiles file
|
|
65
|
+
"""
|
|
66
|
+
# Create output directory if it doesn't exist
|
|
67
|
+
os.makedirs(self.output_path, exist_ok=True)
|
|
68
|
+
geojson_path = self._ensure_geojson(self.input_path)
|
|
69
|
+
|
|
70
|
+
# Define output PMTiles file path
|
|
71
|
+
pmtiles_path = self.output_path / f"{self.input_path.stem}.pmtiles"
|
|
72
|
+
|
|
73
|
+
# Build Tippecanoe command for PMTiles output
|
|
74
|
+
cmd = [
|
|
75
|
+
self.tippecanoe_path,
|
|
76
|
+
"-o",
|
|
77
|
+
str(pmtiles_path),
|
|
78
|
+
"-z",
|
|
79
|
+
str(max_zoom),
|
|
80
|
+
"-Z",
|
|
81
|
+
str(min_zoom),
|
|
82
|
+
"-l",
|
|
83
|
+
self.input_path.stem,
|
|
84
|
+
"--force", # Overwrite existing files
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# Add additional options
|
|
88
|
+
for key, value in kwargs.items():
|
|
89
|
+
if len(key) == 1:
|
|
90
|
+
cmd.append(f"-{key}")
|
|
91
|
+
else:
|
|
92
|
+
cmd.append(f"--{key.replace('_', '-')}")
|
|
93
|
+
|
|
94
|
+
if value is not True: # Only add value if it's not a boolean flag
|
|
95
|
+
cmd.append(str(value))
|
|
96
|
+
|
|
97
|
+
# Add input file
|
|
98
|
+
cmd.append(str(geojson_path))
|
|
99
|
+
|
|
100
|
+
logger.debug(f"Running command: {' '.join(cmd)}")
|
|
101
|
+
|
|
102
|
+
# Run Tippecanoe
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
105
|
+
logger.debug(f"Tippecanoe output: {result.stdout}")
|
|
106
|
+
except subprocess.CalledProcessError as e:
|
|
107
|
+
logger.error(f"Tippecanoe error: {e.stderr}")
|
|
108
|
+
raise RuntimeError(f"Tippecanoe conversion failed: {e.stderr}")
|
|
109
|
+
|
|
110
|
+
return self.output_path
|
|
111
|
+
|
|
112
|
+
def _ensure_geojson(self, input_path: Path) -> Path:
|
|
113
|
+
"""
|
|
114
|
+
Ensure the input file is in GeoJSON format, converting if necessary.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
input_path: Path to the input file
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Path: Path to the GeoJSON file
|
|
121
|
+
"""
|
|
122
|
+
# If already GeoJSON, return as is
|
|
123
|
+
if input_path.suffix.lower() in (".geojson", ".json"):
|
|
124
|
+
return input_path
|
|
125
|
+
|
|
126
|
+
# Convert to GeoJSON
|
|
127
|
+
logger.debug(f"Converting {input_path} to GeoJSON")
|
|
128
|
+
gdf = gpd.read_file(input_path)
|
|
129
|
+
|
|
130
|
+
# Create a temporary GeoJSON file
|
|
131
|
+
geojson_path = input_path.with_suffix(".geojson")
|
|
132
|
+
gdf.to_file(geojson_path, driver="GeoJSON")
|
|
133
|
+
|
|
134
|
+
return geojson_path
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
from starlette.responses import (
|
|
7
|
+
FileResponse,
|
|
8
|
+
PlainTextResponse,
|
|
9
|
+
Response,
|
|
10
|
+
StreamingResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from vectortileserver.logger import logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from vectortileserver.server import TileServer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _file_iterator(path: Path, start: int, length: int, chunk_size: int = 8192) -> Iterator[bytes]:
|
|
20
|
+
"""
|
|
21
|
+
Stream file content in chunks.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
path: Path to the file
|
|
25
|
+
start: Starting byte position
|
|
26
|
+
length: Number of bytes to read
|
|
27
|
+
chunk_size: Size of chunks to read
|
|
28
|
+
|
|
29
|
+
Yields:
|
|
30
|
+
Chunks of file data
|
|
31
|
+
"""
|
|
32
|
+
with open(path, "rb") as f:
|
|
33
|
+
f.seek(start)
|
|
34
|
+
bytes_read = 0
|
|
35
|
+
while bytes_read < length:
|
|
36
|
+
chunk = f.read(min(chunk_size, length - bytes_read))
|
|
37
|
+
if not chunk:
|
|
38
|
+
break
|
|
39
|
+
bytes_read += len(chunk)
|
|
40
|
+
yield chunk
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_file_path(file_path: Path, tile_server_instance: "TileServer") -> Optional[str]:
|
|
44
|
+
"""
|
|
45
|
+
Validate that a file path is within allowed directories.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
file_path: Path to validate
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Error message if validation fails, None if valid
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
resolved_path = file_path.resolve()
|
|
56
|
+
for allowed_dir in tile_server_instance.config.allowed_directories:
|
|
57
|
+
try:
|
|
58
|
+
resolved_path.relative_to(allowed_dir)
|
|
59
|
+
return None
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
return "Access denied: File path is outside allowed directories"
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return f"Invalid file path: {e!s}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def pmtiles_endpoint(request: Request, tile_server_instance: "TileServer") -> Response:
|
|
69
|
+
"""
|
|
70
|
+
Serve a byte-range of the requested PMTiles file.
|
|
71
|
+
The client must specify the filename in the URL.
|
|
72
|
+
"""
|
|
73
|
+
logger.debug("Serving PMTiles file from endpoint")
|
|
74
|
+
file_path_str = request.query_params.get("filePath")
|
|
75
|
+
if not file_path_str:
|
|
76
|
+
return PlainTextResponse("Filename not specified", status_code=400)
|
|
77
|
+
|
|
78
|
+
file_path = Path(file_path_str)
|
|
79
|
+
validation_error = _validate_file_path(file_path, tile_server_instance)
|
|
80
|
+
if validation_error:
|
|
81
|
+
return PlainTextResponse(validation_error, status_code=403)
|
|
82
|
+
|
|
83
|
+
if not file_path.exists():
|
|
84
|
+
return PlainTextResponse("PMTiles file not found", status_code=404)
|
|
85
|
+
|
|
86
|
+
file_size = file_path.stat().st_size
|
|
87
|
+
range_header = request.headers.get("range")
|
|
88
|
+
|
|
89
|
+
if range_header is None:
|
|
90
|
+
return FileResponse(
|
|
91
|
+
path=str(file_path),
|
|
92
|
+
media_type="application/octet-stream",
|
|
93
|
+
filename=str(file_path.name),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
|
97
|
+
if not range_match:
|
|
98
|
+
return PlainTextResponse("Invalid Range header", status_code=400)
|
|
99
|
+
|
|
100
|
+
start = int(range_match.group(1))
|
|
101
|
+
end_str = range_match.group(2)
|
|
102
|
+
end = int(end_str) if end_str else file_size - 1
|
|
103
|
+
|
|
104
|
+
logger.debug(f"Range request: {start}-{end}")
|
|
105
|
+
|
|
106
|
+
if start >= file_size:
|
|
107
|
+
return PlainTextResponse("Requested Range Not Satisfiable", status_code=416)
|
|
108
|
+
if end >= file_size:
|
|
109
|
+
end = file_size - 1
|
|
110
|
+
|
|
111
|
+
length = end - start + 1
|
|
112
|
+
|
|
113
|
+
headers = {
|
|
114
|
+
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
115
|
+
"Accept-Ranges": "bytes",
|
|
116
|
+
"Content-Length": str(length),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Use streaming response for better memory management
|
|
120
|
+
return StreamingResponse(
|
|
121
|
+
_file_iterator(file_path, start, length),
|
|
122
|
+
status_code=206,
|
|
123
|
+
headers=headers,
|
|
124
|
+
media_type="application/octet-stream",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def shutdown_endpoint(request: Request, tile_server_instance: "TileServer") -> Response:
|
|
129
|
+
"""Endpoint to trigger server shutdown."""
|
|
130
|
+
|
|
131
|
+
tile_server_instance.shutdown_event.set()
|
|
132
|
+
return PlainTextResponse("Server shutdown initiated", media_type="text/plain")
|