python-template-project 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,397 @@
1
+ import math
2
+ import zipfile
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import gpxpy
7
+ import srtm
8
+ from gpxpy.gpx import GPX, GPXTrackPoint, GPXWaypoint, GPXXMLSyntaxException
9
+
10
+ NAME = "python_template_project"
11
+
12
+
13
+ class BaseGPXProcessor:
14
+ def __init__(
15
+ self,
16
+ input_: str | Path | list[str],
17
+ output=None,
18
+ min_dist=10,
19
+ date_format="%Y-%m-%d",
20
+ elevation=True,
21
+ logger=None,
22
+ ):
23
+ # ensure that input is converted into a list[Path]
24
+ if isinstance(input_, str):
25
+ self.input = [Path(input_)]
26
+ elif isinstance(input_, Path):
27
+ self.input = [input_]
28
+ elif isinstance(input_, list):
29
+ self.input = [Path(p) for p in input_ if isinstance(p, str | Path)]
30
+ else:
31
+ raise ValueError("Input must be a string, Path, or list of strings/Paths.")
32
+
33
+ self.output = output
34
+ self.min_dist = min_dist
35
+ self.date_format = date_format
36
+ self.include_elevation = elevation
37
+
38
+ # Initialize SRTM elevation data
39
+ self.elevation_data = srtm.get_data()
40
+ self.logger = logger
41
+
42
+ def _get_output_folder(self) -> Path:
43
+ """Get the output folder path, create if not exists."""
44
+ if self.output:
45
+ output_path = Path(self.output)
46
+ else:
47
+ timestamp = datetime.now().strftime(f"{self.date_format}_%H%M")
48
+ output_path = Path.cwd() / f"gpx_processed_{timestamp}"
49
+
50
+ output_path.mkdir(parents=True, exist_ok=True)
51
+ return output_path
52
+
53
+ def _get_adjusted_elevation(self, point: GPXTrackPoint) -> int | float:
54
+ """Get adjusted elevation from SRTM data, fallback to original elevation."""
55
+ try:
56
+ srtm_elevation = self.elevation_data.get_elevation(point.latitude, point.longitude)
57
+ if srtm_elevation is not None:
58
+ return round(srtm_elevation, 1)
59
+ except Exception:
60
+ pass
61
+
62
+ # Fallback to original elevation or 0
63
+ return round(point.elevation or 0, 1)
64
+
65
+ @staticmethod
66
+ def _calculate_distance(point1: GPXTrackPoint, point2: GPXTrackPoint) -> float:
67
+ """Calculate distance between two GPX points in meters using Haversine formula."""
68
+ lat1, lon1 = math.radians(point1.latitude), math.radians(point1.longitude)
69
+ lat2, lon2 = math.radians(point2.latitude), math.radians(point2.longitude)
70
+
71
+ dlat = lat2 - lat1
72
+ dlon = lon2 - lon1
73
+
74
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
75
+ c = 2 * math.asin(math.sqrt(a))
76
+
77
+ # Earth's radius in meters
78
+ earth_radius = 6371000
79
+ return earth_radius * c
80
+
81
+ def _optimize_track_points(
82
+ self, track_points: list[GPXTrackPoint] | list[GPXWaypoint]
83
+ ) -> list[GPXTrackPoint]:
84
+ """Optimize track points by removing close points and cleaning metadata."""
85
+ if not track_points:
86
+ return track_points
87
+
88
+ optimized_points = [track_points[0]] # Always keep first point
89
+
90
+ for point in track_points[1:]:
91
+ # Check distance to last kept point
92
+ if self._calculate_distance(optimized_points[-1], point) >= self.min_dist:
93
+ optimized_points.append(point)
94
+
95
+ # Always keep last point if it's different from the last kept point
96
+ if len(track_points) > 1 and optimized_points[-1] != track_points[-1]:
97
+ optimized_points.append(track_points[-1])
98
+
99
+ # Clean and optimize each point
100
+ for point in optimized_points:
101
+ # Remove time information
102
+ point.time = None
103
+
104
+ # Round coordinates to 5 decimal places
105
+ point.latitude = round(point.latitude, 5)
106
+ point.longitude = round(point.longitude, 5)
107
+
108
+ # Set optimized elevation
109
+ if self.include_elevation:
110
+ point.elevation = self._get_adjusted_elevation(point)
111
+ else:
112
+ point.elevation = None
113
+
114
+ # Remove unnecessary extensions and metadata
115
+ point.extensions = None
116
+ point.symbol = None
117
+ point.type = None
118
+ point.comment = None
119
+ point.description = None
120
+ point.source = None
121
+ point.link = None
122
+ point.link_text = None
123
+ point.link_type = None
124
+ point.horizontal_dilution = None
125
+ point.vertical_dilution = None
126
+ point.position_dilution = None
127
+ point.age_of_dgps_data = None
128
+ point.dgps_id = None
129
+
130
+ return optimized_points
131
+
132
+ def _get_gpx_files(self) -> list[Path]:
133
+ """Get all GPX files from input (file, folder, or zip)."""
134
+ gpx_files = []
135
+ for input_path in self.input:
136
+ if not isinstance(input_path, Path):
137
+ input_path = Path(input_path)
138
+ self.logger.debug(f"Input path: {input_path.absolute()}")
139
+
140
+ if input_path.is_file():
141
+ if input_path.suffix.lower() == ".gpx":
142
+ gpx_files.append(input_path)
143
+ elif input_path.suffix.lower() == ".zip":
144
+ gpx_files.extend(self._extract_gpx_from_zip(input_path))
145
+ elif input_path.is_dir():
146
+ # Get all GPX files in directory
147
+ gpx_files.extend(input_path.glob("*.gpx"))
148
+
149
+ # Get GPX files from ZIP files in directory
150
+ for zip_file in input_path.glob("*.zip"):
151
+ gpx_files.extend(self._extract_gpx_from_zip(zip_file))
152
+
153
+ return gpx_files
154
+
155
+ def _extract_gpx_from_zip(self, zip_path: Path) -> list[Path]:
156
+ """Extract GPX files from ZIP archive to temporary location."""
157
+ gpx_files = []
158
+ temp_dir = Path.cwd() / "temp_gpx_extract"
159
+ temp_dir.mkdir(exist_ok=True)
160
+
161
+ try:
162
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
163
+ for file_info in zip_ref.infolist():
164
+ if file_info.filename.lower().endswith(".gpx"):
165
+ extracted_path = temp_dir / Path(file_info.filename).name
166
+ with open(extracted_path, "wb") as f:
167
+ f.write(zip_ref.read(file_info.filename))
168
+ gpx_files.append(extracted_path)
169
+ except Exception as e:
170
+ self.logger.error(f"Error extracting ZIP file {zip_path}: {e}")
171
+
172
+ return gpx_files
173
+
174
+ def _load_gpx_file(self, gpx_path: Path) -> GPX | None:
175
+ """Load and parse GPX file."""
176
+ try:
177
+ with open(gpx_path, "r", encoding="utf-8") as f:
178
+ return gpxpy.parse(f)
179
+ except GPXXMLSyntaxException as e:
180
+ self.logger.error(f"Error parsing GPX file {gpx_path}: {e}")
181
+ return None
182
+ except Exception as e:
183
+ self.logger.error(f"Error loading GPX file {gpx_path}: {e}")
184
+ return None
185
+
186
+ def _save_gpx_file(self, gpx: GPX, output_path: Path, original_file: Path | None = None):
187
+ """Save GPX object to file."""
188
+ try:
189
+ with open(output_path, "w", encoding="utf-8") as f:
190
+ f.write(gpx.to_xml())
191
+ if original_file and original_file.exists():
192
+ self.logger.info(
193
+ f"Original gpx file size: "
194
+ f"{Path(original_file).stat().st_size / 1024:.2f} KB"
195
+ )
196
+ self.logger.info(
197
+ f"Processed gpx file size: {output_path.stat().st_size / 1024:.2f} KB"
198
+ )
199
+
200
+ except Exception as e:
201
+ self.logger.error(f"Error saving GPX file {output_path}: {e}")
202
+
203
+ def compress_files(self):
204
+ """Shrink the size of all given gpx files in self.input."""
205
+ gpx_files = self._get_gpx_files()
206
+ output_folder = self._get_output_folder()
207
+
208
+ self.logger.info(f"Processing {len(gpx_files)} GPX files...")
209
+
210
+ for gpx_file in gpx_files:
211
+ gpx = self._load_gpx_file(gpx_file)
212
+ if gpx is None:
213
+ continue
214
+
215
+ # process and clean waypoints
216
+ for waypoint in gpx.waypoints:
217
+ self._optimize_waypoint(waypoint)
218
+
219
+ # Process all tracks
220
+ for track in gpx.tracks:
221
+ for segment in track.segments:
222
+ segment.points = self._optimize_track_points(segment.points)
223
+
224
+ # Process all routes
225
+ for route in gpx.routes:
226
+ route.points = self._optimize_track_points(route.points)
227
+
228
+ # Clean GPX metadata
229
+ gpx.time = None
230
+ gpx.extensions = None
231
+
232
+ # Save compressed file
233
+ output_path = output_folder / f"compressed_{gpx_file.name}"
234
+ self._save_gpx_file(gpx, output_path, gpx_file)
235
+ self.logger.info(f"Compressed: {gpx_file.name} -> {output_path}")
236
+
237
+ def _optimize_waypoint(self, waypoint):
238
+ # Round coordinates and elevation
239
+ waypoint.latitude = round(waypoint.latitude, 5)
240
+ waypoint.longitude = round(waypoint.longitude, 5)
241
+ if self.include_elevation:
242
+ waypoint.elevation = self._get_adjusted_elevation(waypoint)
243
+ else:
244
+ waypoint.elevation = None
245
+
246
+ # Clean metadata
247
+ waypoint.time = None
248
+ waypoint.extensions = None
249
+ waypoint.symbol = None
250
+ waypoint.type = None
251
+ waypoint.comment = None
252
+ waypoint.description = None
253
+ waypoint.source = None
254
+ waypoint.link = None
255
+ waypoint.link_text = None
256
+ waypoint.link_type = None
257
+ return waypoint
258
+
259
+ def merge_files(self):
260
+ """Merge all files of self.input into one gpx file with reduced resolution."""
261
+ gpx_files = self._get_gpx_files()
262
+ output_folder = self._get_output_folder()
263
+
264
+ if not gpx_files:
265
+ self.logger.error("No GPX files found to merge.")
266
+ return
267
+
268
+ self.logger.info(f"Merging {len(gpx_files)} GPX files...")
269
+
270
+ # Create new GPX object
271
+ merged_gpx = gpxpy.gpx.GPX()
272
+ merged_gpx.name = "Merged GPX Tracks"
273
+ merged_gpx.description = (
274
+ f"Merged from {len(gpx_files)} GPX files: {', '.join(str(f.name) for f in gpx_files)}"
275
+ )
276
+
277
+ track_counter = 1
278
+
279
+ for gpx_file in gpx_files:
280
+ gpx = self._load_gpx_file(gpx_file)
281
+ if gpx is None:
282
+ continue
283
+
284
+ # Add all waypoints from this file
285
+ for waypoint in gpx.waypoints:
286
+ waypoint = self._optimize_waypoint(waypoint)
287
+ waypoint.name = f"{waypoint.name}_{track_counter}"
288
+ merged_gpx.waypoints.append(waypoint)
289
+
290
+ # Add all tracks from this file
291
+ for track in gpx.tracks:
292
+ new_track = gpxpy.gpx.GPXTrack()
293
+ new_track.name = f"{track.name or track_counter}_{gpx_file.stem}"
294
+
295
+ for segment in track.segments:
296
+ optimized_points = self._optimize_track_points(segment.points)
297
+ if optimized_points:
298
+ new_segment = gpxpy.gpx.GPXTrackSegment()
299
+ new_segment.points = optimized_points
300
+ new_track.segments.append(new_segment)
301
+
302
+ if new_track.segments:
303
+ merged_gpx.tracks.append(new_track)
304
+ track_counter += 1
305
+
306
+ # Add all routes from this file
307
+ for route in gpx.routes:
308
+ new_route = gpxpy.gpx.GPXRoute()
309
+ new_route.name = f"{route.name or track_counter}_{gpx_file.stem}"
310
+ new_route.points = self._optimize_track_points(route.points)
311
+
312
+ if new_route.points:
313
+ merged_gpx.routes.append(new_route)
314
+ track_counter += 1
315
+
316
+ # Save merged file
317
+ output_path = output_folder / "merged_tracks.gpx"
318
+ self._save_gpx_file(merged_gpx, output_path)
319
+ self.logger.info(f"Merged file saved: {output_path}")
320
+
321
+ def extract_pois(self):
322
+ """Merge every starting point of each track in all files
323
+ into one gpx file with many pois."""
324
+
325
+ gpx_files = self._get_gpx_files()
326
+ output_folder = self._get_output_folder()
327
+
328
+ if not gpx_files:
329
+ self.logger.error("No GPX files found to extract POIs from.")
330
+ return
331
+
332
+ self.logger.info(f"Extracting POIs from {len(gpx_files)} GPX files...")
333
+
334
+ # Create new GPX object for waypoints
335
+ poi_gpx = gpxpy.gpx.GPX()
336
+ poi_gpx.name = "Extracted Track Starting Points"
337
+ poi_gpx.description = f"Starting points extracted from {len(gpx_files)} GPX files"
338
+
339
+ poi_counter = 1
340
+
341
+ for gpx_file in gpx_files:
342
+ gpx = self._load_gpx_file(gpx_file)
343
+ if gpx is None:
344
+ continue
345
+
346
+ # Extract starting points from tracks
347
+ for track_idx, track in enumerate(gpx.tracks):
348
+ for _segment_idx, segment in enumerate(track.segments):
349
+ if segment.points:
350
+ start_point = segment.points[0]
351
+
352
+ # Create waypoint from starting point
353
+ waypoint = gpxpy.gpx.GPXWaypoint(
354
+ latitude=round(start_point.latitude, 5),
355
+ longitude=round(start_point.longitude, 5),
356
+ elevation=self._get_adjusted_elevation(start_point),
357
+ )
358
+
359
+ track_name = track.name or f"Track_{track_idx + 1}"
360
+ waypoint.name = f"POI_{poi_counter:03d}"
361
+ waypoint.description = f"Start of {track_name} from {gpx_file.name}"
362
+ waypoint.type = "Track Start"
363
+
364
+ poi_gpx.waypoints.append(waypoint)
365
+ poi_counter += 1
366
+
367
+ # Extract starting points from routes
368
+ for route_idx, route in enumerate(gpx.routes):
369
+ if route.points:
370
+ start_point = route.points[0]
371
+
372
+ # Create waypoint from starting point
373
+ waypoint = gpxpy.gpx.GPXWaypoint(
374
+ latitude=round(start_point.latitude, 5),
375
+ longitude=round(start_point.longitude, 5),
376
+ elevation=self._get_adjusted_elevation(start_point),
377
+ )
378
+
379
+ route_name = route.name or f"Route_{route_idx + 1}"
380
+ waypoint.name = f"POI_{poi_counter:03d}"
381
+ waypoint.description = f"Start of {route_name} from {gpx_file.name}"
382
+ waypoint.type = "Route Start"
383
+
384
+ poi_gpx.waypoints.append(waypoint)
385
+ poi_counter += 1
386
+
387
+ # Save POI file
388
+ output_path = output_folder / "extracted_pois.gpx"
389
+ self._save_gpx_file(poi_gpx, output_path)
390
+ self.logger.info(f"POI file saved with {len(poi_gpx.waypoints)} waypoints: {output_path}")
391
+
392
+ # Clean up temporary files
393
+ temp_dir = Path.cwd() / "temp_gpx_extract"
394
+ if temp_dir.exists():
395
+ import shutil
396
+
397
+ shutil.rmtree(temp_dir)
@@ -0,0 +1,219 @@
1
+ """Centralized logging configuration for python_template_project.
2
+
3
+ This module provides a unified logging setup that supports:
4
+ - File logging with rotation
5
+ - GUI integration via custom handler
6
+ - Configurable log levels from config
7
+ - Structured logging with consistent formatting
8
+ """
9
+
10
+ import logging
11
+ import logging.handlers
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from python_template_project.config.config import ConfigParameterManager
16
+
17
+
18
+ class GuiLogHandler(logging.Handler):
19
+ """Custom logging handler that can write to GUI text widgets."""
20
+
21
+ def __init__(self, gui_writer=None):
22
+ super().__init__()
23
+ self.gui_writer = gui_writer
24
+ self.setFormatter(
25
+ logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S")
26
+ )
27
+
28
+ def emit(self, record):
29
+ """Emit a log record to the GUI if writer is available."""
30
+ if self.gui_writer:
31
+ try:
32
+ msg = self.format(record) + "\n"
33
+ self.gui_writer.write(msg)
34
+ except Exception:
35
+ # Fail silently to avoid recursive logging errors
36
+ pass
37
+
38
+
39
+ class LoggerManager:
40
+ """Manages all logging configuration and handlers."""
41
+
42
+ def __init__(self, config: ConfigParameterManager):
43
+ self.config = config
44
+ self.logger = logging.getLogger("python_template_project")
45
+ self.gui_handler = None
46
+ self.file_handler = None
47
+ self.console_handler = None
48
+ self._setup_logging()
49
+
50
+ def _setup_logging(self):
51
+ """Configure all logging handlers and formatters."""
52
+ # Clear any existing handlers
53
+ self.logger.handlers.clear()
54
+
55
+ # Set log level from config
56
+ log_level = getattr(logging, self.config.app.log_level.default.upper())
57
+ self.logger.setLevel(log_level)
58
+
59
+ # Create formatters
60
+ detailed_formatter = logging.Formatter(
61
+ "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
62
+ )
63
+ simple_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
64
+
65
+ # Setup file handler with rotation
66
+ self._setup_file_handler(detailed_formatter)
67
+
68
+ # Setup console handler
69
+ self._setup_console_handler(simple_formatter)
70
+
71
+ # Setup GUI handler (will be connected later if needed)
72
+ self._setup_gui_handler()
73
+
74
+ def _setup_file_handler(self, formatter):
75
+ """Setup rotating file handler."""
76
+ log_dir = Path("logs")
77
+ log_dir.mkdir(exist_ok=True)
78
+
79
+ log_file = log_dir / "python_template_project.log"
80
+
81
+ # Use RotatingFileHandler to prevent huge log files
82
+ self.file_handler = logging.handlers.RotatingFileHandler(
83
+ log_file,
84
+ maxBytes=10 * 1024 * 1024, # 10MB
85
+ backupCount=5,
86
+ encoding="utf-8",
87
+ )
88
+ self.file_handler.setFormatter(formatter)
89
+ self.logger.addHandler(self.file_handler)
90
+
91
+ def _setup_console_handler(self, formatter):
92
+ """Setup console handler for CLI output."""
93
+ self.console_handler = logging.StreamHandler(sys.stdout)
94
+ self.console_handler.setFormatter(formatter)
95
+ self.logger.addHandler(self.console_handler)
96
+
97
+ def _setup_gui_handler(self):
98
+ """Setup GUI handler (initially without writer)."""
99
+ self.gui_handler = GuiLogHandler()
100
+ # Don't add to logger yet - will be done when GUI connects
101
+
102
+ def connect_gui_writer(self, gui_writer):
103
+ """Connect a GUI writer to the logging system.
104
+
105
+ Args:
106
+ gui_writer: Object with write() method (like the LogHandler from gui.py)
107
+ """
108
+ if self.gui_handler:
109
+ # Remove old handler if it exists
110
+ if self.gui_handler in self.logger.handlers:
111
+ self.logger.removeHandler(self.gui_handler)
112
+
113
+ # Create new handler with GUI writer
114
+ self.gui_handler = GuiLogHandler(gui_writer)
115
+ self.logger.addHandler(self.gui_handler)
116
+
117
+ def disconnect_gui_writer(self):
118
+ """Disconnect GUI writer (useful when GUI closes)."""
119
+ if self.gui_handler and self.gui_handler in self.logger.handlers:
120
+ self.logger.removeHandler(self.gui_handler)
121
+
122
+ def get_logger(self, name: str = None) -> logging.Logger:
123
+ """Get a logger instance.
124
+
125
+ Args:
126
+ name: Logger name (defaults to main project logger)
127
+
128
+ Returns:
129
+ Logger instance
130
+ """
131
+ if name:
132
+ return logging.getLogger(f"python_template_project.{name}")
133
+ return self.logger
134
+
135
+ def set_log_level(self, level: str):
136
+ """Change log level dynamically.
137
+
138
+ Args:
139
+ level: New log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
140
+ """
141
+ log_level = getattr(logging, level.upper())
142
+ self.logger.setLevel(log_level)
143
+
144
+ # Update config
145
+ self.config.app.log_level.default = level.upper()
146
+
147
+ def log_config_summary(self):
148
+ """Log current configuration summary."""
149
+ self.logger.info("=== Configuration Summary ===")
150
+ self.logger.info(f"Log level: {self.config.app.log_level.default}")
151
+ self.logger.info(f"Input: {self.config.cli.input.default}")
152
+ self.logger.info(f"Output: {self.config.cli.output.default}")
153
+ self.logger.info(f"Max workers: {self.config.app.max_workers.default}")
154
+ self.logger.info("==============================")
155
+
156
+
157
+ # Global logger manager instance
158
+ _logger_manager = None
159
+
160
+
161
+ def initialize_logging(config: ConfigParameterManager) -> LoggerManager:
162
+ """Initialize the global logging system.
163
+
164
+ Args:
165
+ config: Configuration manager instance
166
+
167
+ Returns:
168
+ LoggerManager instance
169
+ """
170
+ global _logger_manager
171
+ _logger_manager = LoggerManager(config)
172
+ return _logger_manager
173
+
174
+
175
+ def get_logger(name: str = None) -> logging.Logger:
176
+ """Get a logger instance.
177
+
178
+ Args:
179
+ name: Logger name (optional)
180
+
181
+ Returns:
182
+ Logger instance
183
+
184
+ Raises:
185
+ RuntimeError: If logging not initialized
186
+ """
187
+ if _logger_manager is None:
188
+ raise RuntimeError("Logging not initialized. Call initialize_logging() first.")
189
+ return _logger_manager.get_logger(name)
190
+
191
+
192
+ def get_logger_manager() -> LoggerManager:
193
+ """Get the global logger manager.
194
+
195
+ Returns:
196
+ LoggerManager instance
197
+
198
+ Raises:
199
+ RuntimeError: If logging not initialized
200
+ """
201
+ if _logger_manager is None:
202
+ raise RuntimeError("Logging not initialized. Call initialize_logging() first.")
203
+ return _logger_manager
204
+
205
+
206
+ def connect_gui_logging(gui_writer):
207
+ """Connect GUI writer to logging system.
208
+
209
+ Args:
210
+ gui_writer: GUI writer object with write() method
211
+ """
212
+ if _logger_manager:
213
+ _logger_manager.connect_gui_writer(gui_writer)
214
+
215
+
216
+ def disconnect_gui_logging():
217
+ """Disconnect GUI from logging system."""
218
+ if _logger_manager:
219
+ _logger_manager.disconnect_gui_writer()
File without changes
@@ -0,0 +1,8 @@
1
+ """GUI entry point for project_name."""
2
+
3
+ import sys # pragma: no cover
4
+
5
+ from .gui import main # pragma: no cover
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ sys.exit(main())