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.
@@ -0,0 +1,8 @@
1
+ __title__ = "vectortileserver"
2
+ __summary__ = "Local vector tile server for visualizing PMTiles in Jupyter"
3
+ __version__ = "0.1.0"
4
+
5
+ __author__ = "Daniel Guerrero"
6
+ __email__ = "dfgm2006@gmail.com"
7
+
8
+ __license__ = "MIT"
@@ -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")