pumaguard 20.post174__py3-none-any.whl → 20.post189__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.
pumaguard/main.py CHANGED
@@ -18,6 +18,7 @@ from pumaguard.presets import (
18
18
  Preset,
19
19
  PresetError,
20
20
  get_default_settings_file,
21
+ get_xdg_cache_home,
21
22
  )
22
23
  from pumaguard.utils import (
23
24
  print_bash_completion,
@@ -182,7 +183,12 @@ def main():
182
183
  logging.basicConfig(level=logging.INFO)
183
184
  logger = logging.getLogger("PumaGuard")
184
185
 
185
- file_handler = logging.FileHandler("pumaguard.log")
186
+ # Store logs in XDG cache directory
187
+ log_dir = get_xdg_cache_home() / "pumaguard"
188
+ log_dir.mkdir(parents=True, exist_ok=True)
189
+ log_file = log_dir / "pumaguard.log"
190
+
191
+ file_handler = logging.FileHandler(str(log_file))
186
192
  file_handler.setLevel(logging.DEBUG)
187
193
  formatter = logging.Formatter(
188
194
  "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
pumaguard/presets.py CHANGED
@@ -36,7 +36,7 @@ def get_xdg_config_home() -> Path:
36
36
 
37
37
  def get_xdg_data_home() -> Path:
38
38
  """
39
- Get the XDG data home directory according to XDG Base Directory spec.
39
+ Get the XDG data home directory.
40
40
 
41
41
  Returns:
42
42
  Path to XDG_DATA_HOME (defaults to ~/.local/share if not set)
@@ -47,6 +47,19 @@ def get_xdg_data_home() -> Path:
47
47
  return Path.home() / ".local" / "share"
48
48
 
49
49
 
50
+ def get_xdg_cache_home() -> Path:
51
+ """
52
+ Get the XDG cache home directory.
53
+
54
+ Returns:
55
+ Path to XDG_CACHE_HOME (defaults to ~/.cache if not set)
56
+ """
57
+ xdg_cache = os.environ.get("XDG_CACHE_HOME")
58
+ if xdg_cache:
59
+ return Path(xdg_cache)
60
+ return Path.home() / ".cache"
61
+
62
+
50
63
  def get_default_settings_file() -> str:
51
64
  """
52
65
  Get the default settings file path using XDG standards.
@@ -38,6 +38,6 @@ _flutter.buildConfig = {"engineRevision":"13e658725ddaa270601426d1485636157e38c3
38
38
 
39
39
  _flutter.loader.load({
40
40
  serviceWorkerSettings: {
41
- serviceWorkerVersion: "1223792320"
41
+ serviceWorkerVersion: "181878644"
42
42
  }
43
43
  });
@@ -34,7 +34,7 @@ const RESOURCES = {"flutter.js": "24bc71911b75b5f8135c949e27a2984e",
34
34
  "canvaskit/skwasm.js.symbols": "3a4aadf4e8141f284bd524976b1d6bdc",
35
35
  "favicon.png": "5dcef449791fa27946b3d35ad8803796",
36
36
  "main.dart.wasm": "b7e85784e7cc42b937c6ddffca885d19",
37
- "flutter_bootstrap.js": "0beccbfc72182c009b036ef5162ced6c",
37
+ "flutter_bootstrap.js": "4a763a346d06c77e69652e5a31b9a5b8",
38
38
  "version.json": "d3ae24eea88b92d3d617cfcd1d6d057d",
39
39
  "main.dart.js": "8d76816e4e65d2d9504d5fd4013ba7cf"};
40
40
  // The application shell files that are downloaded before a service worker can
pumaguard/server.py CHANGED
@@ -394,6 +394,24 @@ def main(options: argparse.Namespace, presets: Preset):
394
394
  webui.add_image_directory(folder)
395
395
  logger.info("Watching folder: %s", folder)
396
396
 
397
+ # Also expose classified result folders in the UI (browse-only)
398
+ try:
399
+ Path(presets.classified_puma_dir).mkdir(parents=True, exist_ok=True)
400
+ Path(presets.classified_other_dir).mkdir(parents=True, exist_ok=True)
401
+ Path(presets.intermediate_dir).mkdir(parents=True, exist_ok=True)
402
+ except OSError as exc: # pragma: no cover
403
+ logger.error("Could not ensure classified folders exist: %s", exc)
404
+
405
+ webui.add_image_directory(presets.classified_puma_dir)
406
+ webui.add_image_directory(presets.classified_other_dir)
407
+ webui.add_image_directory(presets.intermediate_dir)
408
+ logger.info(
409
+ "Classified browsing enabled for: %s, %s; intermediate: %s",
410
+ presets.classified_puma_dir,
411
+ presets.classified_other_dir,
412
+ presets.intermediate_dir,
413
+ )
414
+
397
415
  manager.start_all()
398
416
 
399
417
  lock = acquire_lock()
File without changes
@@ -0,0 +1,114 @@
1
+ """Artifacts routes for intermediate visualizations and CSVs."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ import os
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ )
11
+
12
+ from flask import (
13
+ jsonify,
14
+ request,
15
+ send_from_directory,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from flask import (
20
+ Flask,
21
+ )
22
+
23
+ from pumaguard.web_ui import (
24
+ WebUI,
25
+ )
26
+
27
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
28
+
29
+
30
+ def register_artifacts_routes(app: "Flask", webui: "WebUI") -> None:
31
+ """Register artifacts endpoints for list and download."""
32
+
33
+ @app.route("/api/artifacts", methods=["GET"])
34
+ def list_artifacts():
35
+ base_dir = os.path.realpath(
36
+ os.path.normpath(webui.presets.intermediate_dir)
37
+ )
38
+ if not os.path.exists(base_dir):
39
+ return jsonify(
40
+ {"artifacts": [], "total": 0, "directory": base_dir}
41
+ )
42
+ ext_param = request.args.get("ext", default=None, type=str)
43
+ ext_filter: set[str] | None = None
44
+ if ext_param:
45
+ parts = [
46
+ p.strip().lower() for p in ext_param.split(",") if p.strip()
47
+ ]
48
+ ext_filter = {p if p.startswith(".") else f".{p}" for p in parts}
49
+ limit = request.args.get("limit", default=None, type=int)
50
+ entries: list[dict[str, object]] = []
51
+ try:
52
+ for filename in os.listdir(base_dir):
53
+ filepath = os.path.join(base_dir, filename)
54
+ if not os.path.isfile(filepath):
55
+ continue
56
+ ext = os.path.splitext(filename)[1].lower()
57
+ if ext_filter is not None and ext not in ext_filter:
58
+ continue
59
+ stat = os.stat(filepath)
60
+ kind = (
61
+ "image"
62
+ if ext in IMAGE_EXTS
63
+ else ("csv" if ext == ".csv" else "file")
64
+ )
65
+ entries.append(
66
+ {
67
+ "filename": filename,
68
+ "path": filepath,
69
+ "ext": ext,
70
+ "kind": kind,
71
+ "size": stat.st_size,
72
+ "modified": stat.st_mtime,
73
+ "created": stat.st_ctime,
74
+ }
75
+ )
76
+ except OSError:
77
+ return jsonify({"error": "Failed to read artifacts"}), 500
78
+ entries.sort(key=lambda x: x["modified"], reverse=True) # type: ignore
79
+ if limit is not None and limit > 0:
80
+ entries = entries[:limit]
81
+ return jsonify(
82
+ {
83
+ "artifacts": entries,
84
+ "total": len(entries),
85
+ "directory": base_dir,
86
+ }
87
+ )
88
+
89
+ @app.route("/api/artifacts/<path:filepath>", methods=["GET"])
90
+ def get_artifact(filepath: str):
91
+ # Resolve to absolute path and validate within intermediate
92
+ base_dir = os.path.realpath(
93
+ os.path.normpath(webui.presets.intermediate_dir)
94
+ )
95
+ abs_filepath = os.path.realpath(os.path.normpath(filepath))
96
+ try:
97
+ common = os.path.commonpath([abs_filepath, base_dir])
98
+ if common != base_dir:
99
+ return jsonify({"error": "Access denied"}), 403
100
+ except ValueError:
101
+ # Different drives on Windows
102
+ return jsonify({"error": "Access denied"}), 403
103
+ if not os.path.exists(abs_filepath) or not os.path.isfile(
104
+ abs_filepath
105
+ ):
106
+ return jsonify({"error": "File not found"}), 404
107
+ directory = os.path.dirname(abs_filepath)
108
+ filename = os.path.basename(abs_filepath)
109
+ as_attachment = (
110
+ request.args.get("download", default="false").lower() == "true"
111
+ )
112
+ return send_from_directory(
113
+ directory, filename, as_attachment=as_attachment
114
+ )
@@ -0,0 +1,89 @@
1
+ """Diagnostics routes for server status and troubleshooting info."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ )
10
+
11
+ from flask import (
12
+ jsonify,
13
+ request,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from flask import (
18
+ Flask,
19
+ )
20
+
21
+ from pumaguard.web_ui import (
22
+ WebUI,
23
+ )
24
+
25
+
26
+ def register_diagnostics_routes(app: "Flask", webui: "WebUI") -> None:
27
+ """Register diagnostics endpoints for status and debug info."""
28
+
29
+ @app.route("/api/status", methods=["GET"])
30
+ def get_status():
31
+ origin = request.headers.get("Origin", "No Origin header")
32
+ host = request.headers.get("Host", "No Host header")
33
+ return jsonify(
34
+ {
35
+ "status": "running",
36
+ "version": "1.0.0",
37
+ "directories_count": len(webui.image_directories),
38
+ "host": webui.host,
39
+ "port": webui.port,
40
+ "request_origin": origin,
41
+ "request_host": host,
42
+ }
43
+ )
44
+
45
+ @app.route("/api/diagnostic", methods=["GET"])
46
+ def get_diagnostic():
47
+ diagnostic_info = {
48
+ "server": {
49
+ "host": webui.host,
50
+ "port": webui.port,
51
+ "flutter_dir": str(webui.flutter_dir),
52
+ "build_dir": str(webui.build_dir),
53
+ "build_exists": webui.build_dir.exists(),
54
+ "mdns_enabled": webui.mdns_enabled,
55
+ "mdns_name": webui.mdns_name if webui.mdns_enabled else None,
56
+ "mdns_url": (
57
+ f"http://{webui.mdns_name}.local:{webui.port}"
58
+ if webui.mdns_enabled
59
+ else None
60
+ ),
61
+ "local_ip": webui._get_local_ip(), # pylint: disable=protected-access
62
+ },
63
+ "request": {
64
+ "url": request.url,
65
+ "base_url": request.base_url,
66
+ "host": request.headers.get("Host", "N/A"),
67
+ "origin": request.headers.get("Origin", "N/A"),
68
+ "referer": request.headers.get("Referer", "N/A"),
69
+ "user_agent": request.headers.get("User-Agent", "N/A"),
70
+ },
71
+ "expected_behavior": {
72
+ "flutter_app_should_detect": (
73
+ f"{request.scheme}://{request.host}"
74
+ ),
75
+ "api_calls_should_go_to": (
76
+ f"{request.scheme}://{request.host}/api/..."
77
+ ),
78
+ },
79
+ "troubleshooting": {
80
+ "if_api_calls_go_to_localhost": (
81
+ "Browser is using cached old JavaScript - clear cache"
82
+ ),
83
+ "if_page_doesnt_load": (
84
+ "Check that Flutter app is built: make build-ui"
85
+ ),
86
+ "if_cors_errors": "Check browser console for details",
87
+ },
88
+ }
89
+ return jsonify(diagnostic_info)
@@ -0,0 +1,67 @@
1
+ """Directory management routes for adding and removing watch folders."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ import logging
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ )
11
+
12
+ from flask import (
13
+ jsonify,
14
+ request,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from flask import (
19
+ Flask,
20
+ )
21
+
22
+ from pumaguard.web_ui import (
23
+ WebUI,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def register_directories_routes(app: "Flask", webui: "WebUI") -> None:
30
+ """Register directory endpoints for list, add, remove."""
31
+
32
+ @app.route("/api/directories", methods=["GET"])
33
+ def get_directories():
34
+ return jsonify({"directories": webui.image_directories})
35
+
36
+ @app.route("/api/directories", methods=["POST"])
37
+ def add_directory():
38
+ data = request.json
39
+ directory = data.get("directory") if data else None
40
+ if not directory:
41
+ return jsonify({"error": "No directory provided"}), 400
42
+ # Validate existence happens inside webui.add_image_directory
43
+ if directory not in webui.image_directories:
44
+ webui.image_directories.append(directory)
45
+ logger.info("Added image directory: %s", directory)
46
+ if webui.folder_manager is not None:
47
+ webui.folder_manager.register_folder(
48
+ directory, webui.watch_method
49
+ )
50
+ logger.info(
51
+ "Registered folder with manager: %s (method: %s)",
52
+ directory,
53
+ webui.watch_method,
54
+ )
55
+ return jsonify(
56
+ {"success": True, "directories": webui.image_directories}
57
+ )
58
+
59
+ @app.route("/api/directories/<int:index>", methods=["DELETE"])
60
+ def remove_directory(index: int):
61
+ if 0 <= index < len(webui.image_directories):
62
+ removed = webui.image_directories.pop(index)
63
+ logger.info("Removed image directory: %s", removed)
64
+ return jsonify(
65
+ {"success": True, "directories": webui.image_directories}
66
+ )
67
+ return jsonify({"error": "Invalid index"}), 400
@@ -0,0 +1,121 @@
1
+ """
2
+ Folders routes for browsing and listing images.
3
+ """
4
+
5
+ from __future__ import (
6
+ annotations,
7
+ )
8
+
9
+ import os
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ cast,
13
+ )
14
+
15
+ from flask import (
16
+ jsonify,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from flask import (
21
+ Flask,
22
+ )
23
+
24
+ from pumaguard.web_ui import (
25
+ WebUI,
26
+ )
27
+
28
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
29
+
30
+
31
+ def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
32
+ """Register folder endpoints for list and browse images."""
33
+
34
+ @app.route("/api/folders", methods=["GET"])
35
+ def get_folders():
36
+ folders = []
37
+ for directory in webui.image_directories:
38
+ if not os.path.exists(directory):
39
+ continue
40
+ image_count = 0
41
+ for filename in os.listdir(directory):
42
+ filepath = os.path.join(directory, filename)
43
+ if os.path.isfile(filepath):
44
+ ext = os.path.splitext(filename)[1].lower()
45
+ if ext in IMAGE_EXTS:
46
+ image_count += 1
47
+ folders.append(
48
+ {
49
+ "path": directory,
50
+ "name": os.path.basename(directory),
51
+ "image_count": image_count,
52
+ }
53
+ )
54
+ return jsonify({"folders": folders})
55
+
56
+ @app.route("/api/folders/<path:folder_path>/images", methods=["GET"])
57
+ def get_folder_images(folder_path: str):
58
+ # Try to resolve folder_path relative to each allowed image directory
59
+ abs_folder = None
60
+ resolved_base = None
61
+ for directory in webui.image_directories:
62
+ abs_directory = os.path.realpath(os.path.normpath(directory))
63
+ # Always join user input to base, then normalize
64
+ candidate_folder = os.path.realpath(
65
+ os.path.join(abs_directory, folder_path)
66
+ )
67
+ try:
68
+ common = os.path.commonpath([candidate_folder, abs_directory])
69
+ if common == abs_directory:
70
+ abs_folder = candidate_folder
71
+ resolved_base = abs_directory
72
+ break
73
+ except ValueError:
74
+ # Different drives on Windows
75
+ continue
76
+ if abs_folder is None:
77
+ # Ensure file is within the allowed folder
78
+ return jsonify({"error": "Access denied"}), 403
79
+ if not os.path.exists(abs_folder) or not os.path.isdir(abs_folder):
80
+ return jsonify({"error": "Folder not found"}), 404
81
+ images = []
82
+ for filename in os.listdir(abs_folder):
83
+ filepath = os.path.join(abs_folder, filename)
84
+ resolved_filepath = os.path.realpath(os.path.normpath(filepath))
85
+ if (
86
+ os.path.commonpath([resolved_filepath, abs_folder])
87
+ != abs_folder
88
+ ):
89
+ continue
90
+ if os.path.isfile(resolved_filepath):
91
+ ext = os.path.splitext(filename)[1].lower()
92
+ if ext in IMAGE_EXTS:
93
+ stat = os.stat(resolved_filepath)
94
+ rel_file_path = os.path.relpath(
95
+ resolved_filepath, resolved_base
96
+ )
97
+ images.append(
98
+ {
99
+ "filename": filename,
100
+ "path": rel_file_path,
101
+ "size": stat.st_size,
102
+ "modified": stat.st_mtime,
103
+ "created": stat.st_ctime,
104
+ }
105
+ )
106
+
107
+ images.sort(key=lambda x: cast(float, x["modified"]), reverse=True)
108
+ # Return only relative folder path to root and the root directory name
109
+ if resolved_base is not None:
110
+ rel_folder_path = os.path.relpath(abs_folder, resolved_base)
111
+ folder_name = os.path.basename(resolved_base)
112
+ else:
113
+ rel_folder_path = ""
114
+ folder_name = ""
115
+ return jsonify(
116
+ {
117
+ "images": images,
118
+ "folder": rel_folder_path,
119
+ "base": folder_name,
120
+ }
121
+ )
@@ -0,0 +1,112 @@
1
+ """Photos routes for listing, fetching, and deleting images."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ import os
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ )
11
+
12
+ from flask import (
13
+ jsonify,
14
+ send_from_directory,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from flask import (
19
+ Flask,
20
+ )
21
+
22
+ from pumaguard.web_ui import (
23
+ WebUI,
24
+ )
25
+
26
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
27
+
28
+
29
+ def register_photos_routes(app: "Flask", webui: "WebUI") -> None:
30
+ """Register photo endpoints for list, get, and delete."""
31
+
32
+ @app.route("/api/photos", methods=["GET"])
33
+ def get_photos():
34
+ photos: list[dict] = []
35
+ for directory in webui.image_directories:
36
+ if not os.path.exists(directory):
37
+ continue
38
+ for filename in os.listdir(directory):
39
+ filepath = os.path.join(directory, filename)
40
+ if os.path.isfile(filepath):
41
+ ext = os.path.splitext(filename)[1].lower()
42
+ if ext in IMAGE_EXTS:
43
+ stat = os.stat(filepath)
44
+ photos.append(
45
+ {
46
+ "filename": filename,
47
+ "path": filepath,
48
+ "directory": directory,
49
+ "size": stat.st_size,
50
+ "modified": stat.st_mtime,
51
+ "created": stat.st_ctime,
52
+ }
53
+ )
54
+ photos.sort(key=lambda x: x["modified"], reverse=True)
55
+ return jsonify({"photos": photos, "total": len(photos)})
56
+
57
+ @app.route("/api/photos/<path:filepath>", methods=["GET"])
58
+ def get_photo(filepath: str):
59
+ # Safely resolve user provided path against allowed directories
60
+ abs_filepath = None
61
+ for directory in webui.image_directories:
62
+ abs_directory = os.path.realpath(directory)
63
+ joined_path = os.path.join(abs_directory, filepath)
64
+ candidate = os.path.realpath(joined_path)
65
+ try:
66
+ common = os.path.commonpath([candidate, abs_directory])
67
+ if common == abs_directory:
68
+ abs_filepath = candidate
69
+ break
70
+ except ValueError:
71
+ # Different drives on Windows
72
+ continue
73
+ if abs_filepath is None:
74
+ return jsonify({"error": "Access denied"}), 403
75
+ if not os.path.exists(abs_filepath) or not os.path.isfile(
76
+ abs_filepath
77
+ ):
78
+ return jsonify({"error": "File not found"}), 404
79
+ ext = os.path.splitext(abs_filepath)[1].lower()
80
+ if ext not in IMAGE_EXTS:
81
+ return jsonify({"error": "Access denied"}), 403
82
+ directory = os.path.dirname(abs_filepath)
83
+ filename = os.path.basename(abs_filepath)
84
+ return send_from_directory(directory, filename)
85
+
86
+ @app.route("/api/photos/<path:filepath>", methods=["DELETE"])
87
+ def delete_photo(filepath: str):
88
+ # Safely resolve user provided path against allowed directories
89
+ abs_filepath = None
90
+ for directory in webui.image_directories:
91
+ abs_directory = os.path.realpath(directory)
92
+ joined_path = os.path.join(abs_directory, filepath)
93
+ candidate = os.path.realpath(joined_path)
94
+ try:
95
+ common = os.path.commonpath([candidate, abs_directory])
96
+ if common == abs_directory:
97
+ abs_filepath = candidate
98
+ break
99
+ except ValueError:
100
+ # Different drives on Windows
101
+ continue
102
+ if abs_filepath is None:
103
+ return jsonify({"error": "Access denied"}), 403
104
+ if not os.path.exists(abs_filepath) or not os.path.isfile(
105
+ abs_filepath
106
+ ):
107
+ return jsonify({"error": "File not found"}), 404
108
+ ext = os.path.splitext(abs_filepath)[1].lower()
109
+ if ext not in IMAGE_EXTS:
110
+ return jsonify({"error": "Access denied"}), 403
111
+ os.remove(abs_filepath)
112
+ return jsonify({"success": True, "message": "Photo deleted"})