pumaguard 20.post174__py3-none-any.whl → 20.post190__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,116 @@
1
+ """Settings routes for loading, saving, and updating presets."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ import logging
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ )
11
+
12
+ import yaml
13
+ from flask import (
14
+ jsonify,
15
+ request,
16
+ )
17
+ from yaml.representer import (
18
+ YAMLError,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from flask import (
23
+ Flask,
24
+ )
25
+
26
+ from pumaguard.web_ui import (
27
+ WebUI,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def register_settings_routes(app: "Flask", webui: "WebUI") -> None:
34
+ """Register settings endpoints for GET, PUT, save, and load."""
35
+
36
+ @app.route("/api/settings", methods=["GET"])
37
+ def get_settings():
38
+ return jsonify(dict(webui.presets))
39
+
40
+ @app.route("/api/settings", methods=["PUT"])
41
+ def update_settings():
42
+ try:
43
+ data = request.json
44
+ if not data:
45
+ return jsonify({"error": "No data provided"}), 400
46
+
47
+ allowed_settings = [
48
+ "YOLO-min-size",
49
+ "YOLO-conf-thresh",
50
+ "YOLO-max-dets",
51
+ "YOLO-model-filename",
52
+ "classifier-model-filename",
53
+ "deterrent-sound-file",
54
+ "file-stabilization-extra-wait",
55
+ "play-sound",
56
+ ]
57
+
58
+ if len(data) == 0:
59
+ raise ValueError("Did not receive any settings")
60
+
61
+ for key, value in data.items():
62
+ if key in allowed_settings:
63
+ logger.debug("Updating %s with %s", key, value)
64
+ attr_name = key.replace("-", "_").replace("YOLO_", "yolo_")
65
+ setattr(webui.presets, attr_name, value)
66
+ else:
67
+ logger.debug("Skipping unknown/read-only setting: %s", key)
68
+
69
+ try:
70
+ filepath = webui.presets.settings_file
71
+ settings_dict = dict(webui.presets)
72
+ with open(filepath, "w", encoding="utf-8") as f:
73
+ yaml.dump(settings_dict, f, default_flow_style=False)
74
+ logger.info("Settings updated and saved to %s", filepath)
75
+ except YAMLError:
76
+ logger.exception("Error saving settings")
77
+ return (
78
+ jsonify(
79
+ {
80
+ "error": (
81
+ "Settings updated but failed to save due "
82
+ "to an internal error"
83
+ )
84
+ }
85
+ ),
86
+ 500,
87
+ )
88
+
89
+ return jsonify(
90
+ {"success": True, "message": "Settings updated and saved"}
91
+ )
92
+ except ValueError as e: # pragma: no cover (unexpected)
93
+ logger.error("Error updating settings: %s", e)
94
+ return jsonify({"error": str(e)}), 500
95
+
96
+ @app.route("/api/settings/save", methods=["POST"])
97
+ def save_settings():
98
+ data = request.json
99
+ filepath = data.get("filepath") if data else None
100
+ if not filepath:
101
+ filepath = webui.presets.settings_file
102
+ settings_dict = dict(webui.presets)
103
+ with open(filepath, "w", encoding="utf-8") as f:
104
+ yaml.dump(settings_dict, f, default_flow_style=False)
105
+ logger.info("Settings saved to %s", filepath)
106
+ return jsonify({"success": True, "filepath": filepath})
107
+
108
+ @app.route("/api/settings/load", methods=["POST"])
109
+ def load_settings():
110
+ data = request.json
111
+ filepath = data.get("filepath") if data else None
112
+ if not filepath:
113
+ return jsonify({"error": "No filepath provided"}), 400
114
+ webui.presets.load(filepath)
115
+ logger.info("Settings loaded from %s", filepath)
116
+ return jsonify({"success": True, "message": "Settings loaded"})
@@ -0,0 +1,130 @@
1
+ """Sync routes for checksums and batch downloading."""
2
+
3
+ from __future__ import (
4
+ annotations,
5
+ )
6
+
7
+ import hashlib
8
+ import io
9
+ import os
10
+ import zipfile
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ )
14
+
15
+ from flask import (
16
+ jsonify,
17
+ request,
18
+ send_file,
19
+ send_from_directory,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from flask import (
24
+ Flask,
25
+ )
26
+
27
+ from pumaguard.web_ui import (
28
+ WebUI,
29
+ )
30
+
31
+
32
+ def _calculate_file_checksum(filepath: str) -> str:
33
+ """Calculate SHA256 checksum of file."""
34
+ sha256_hash = hashlib.sha256()
35
+ with open(filepath, "rb") as f:
36
+ for byte_block in iter(lambda: f.read(4096), b""):
37
+ sha256_hash.update(byte_block)
38
+ return sha256_hash.hexdigest()
39
+
40
+
41
+ def register_sync_routes(app: "Flask", webui: "WebUI") -> None:
42
+ """Register sync endpoints for file checksums and downloads."""
43
+
44
+ @app.route("/api/sync/checksums", methods=["POST"])
45
+ def calculate_checksums():
46
+ data = request.json
47
+ if not data or "files" not in data:
48
+ return jsonify({"error": "No files provided"}), 400
49
+ client_files = data["files"]
50
+ files_to_download = []
51
+ for filepath, client_checksum in client_files.items():
52
+ # Resolve and validate within allowed directories
53
+ abs_filepath = os.path.realpath(os.path.normpath(filepath))
54
+ allowed = False
55
+ for directory in webui.image_directories:
56
+ abs_directory = os.path.realpath(os.path.normpath(directory))
57
+ try:
58
+ common = os.path.commonpath([abs_filepath, abs_directory])
59
+ if common == abs_directory:
60
+ allowed = True
61
+ break
62
+ except ValueError:
63
+ # Different drives on Windows
64
+ continue
65
+ if not allowed:
66
+ continue
67
+ if not os.path.exists(abs_filepath):
68
+ continue
69
+ server_checksum = _calculate_file_checksum(abs_filepath)
70
+ if server_checksum != client_checksum:
71
+ stat = os.stat(abs_filepath)
72
+ files_to_download.append(
73
+ {
74
+ "path": filepath,
75
+ "checksum": server_checksum,
76
+ "size": stat.st_size,
77
+ "modified": stat.st_mtime,
78
+ }
79
+ )
80
+ return jsonify(
81
+ {
82
+ "files_to_download": files_to_download,
83
+ "total": len(files_to_download),
84
+ }
85
+ )
86
+
87
+ @app.route("/api/sync/download", methods=["POST"])
88
+ def download_files():
89
+ data = request.json
90
+ if not data or "files" not in data:
91
+ return jsonify({"error": "No files provided"}), 400
92
+ file_paths = data["files"]
93
+ validated_files = []
94
+ for filepath in file_paths:
95
+ # Resolve and validate within allowed directories
96
+ abs_filepath = os.path.realpath(os.path.normpath(filepath))
97
+ allowed = False
98
+ for directory in webui.image_directories:
99
+ # Ensure allowed directories are normalized and real paths
100
+ abs_directory = os.path.realpath(os.path.normpath(directory))
101
+ try:
102
+ common = os.path.commonpath([abs_filepath, abs_directory])
103
+ if common == abs_directory and os.path.isfile(
104
+ abs_filepath
105
+ ):
106
+ allowed = True
107
+ break
108
+ except ValueError:
109
+ # Different drives on Windows
110
+ continue
111
+ if allowed and os.path.exists(abs_filepath):
112
+ validated_files.append(abs_filepath)
113
+ if not validated_files:
114
+ return jsonify({"error": "No valid files to download"}), 400
115
+ if len(validated_files) == 1:
116
+ directory = os.path.dirname(validated_files[0])
117
+ filename = os.path.basename(validated_files[0])
118
+ return send_from_directory(directory, filename, as_attachment=True)
119
+ memory_file = io.BytesIO()
120
+ with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf:
121
+ for filepath in validated_files:
122
+ arcname = os.path.basename(filepath)
123
+ zf.write(filepath, arcname)
124
+ memory_file.seek(0)
125
+ return send_file(
126
+ memory_file,
127
+ mimetype="application/zip",
128
+ as_attachment=True,
129
+ download_name="pumaguard_images.zip",
130
+ )