pyzm 2.3.4__tar.gz → 2.3.6__tar.gz

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.
Files changed (131) hide show
  1. {pyzm-2.3.4/pyzm.egg-info → pyzm-2.3.6}/PKG-INFO +1 -1
  2. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/__init__.py +1 -1
  3. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/log.py +41 -6
  4. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/models/config.py +1 -1
  5. {pyzm-2.3.4 → pyzm-2.3.6/pyzm.egg-info}/PKG-INFO +1 -1
  6. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_config_variants.py +1 -1
  7. pyzm-2.3.6/tests/test_face_dlib_lock.py +44 -0
  8. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_log.py +187 -0
  9. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_models/test_config.py +1 -1
  10. pyzm-2.3.4/tests/test_face_dlib_lock.py +0 -42
  11. {pyzm-2.3.4 → pyzm-2.3.6}/LICENSE +0 -0
  12. {pyzm-2.3.4 → pyzm-2.3.6}/MANIFEST.in +0 -0
  13. {pyzm-2.3.4 → pyzm-2.3.6}/README.md +0 -0
  14. {pyzm-2.3.4 → pyzm-2.3.6}/pyproject.toml +0 -0
  15. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/client.py +0 -0
  16. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/__init__.py +0 -0
  17. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/__init__.py +0 -0
  18. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/alpr.py +0 -0
  19. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/base.py +0 -0
  20. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/birdnet.py +0 -0
  21. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/coral.py +0 -0
  22. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/face_dlib.py +0 -0
  23. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/face_tpu.py +0 -0
  24. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/rekognition.py +0 -0
  25. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/yolo.py +0 -0
  26. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/yolo_darknet.py +0 -0
  27. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/backends/yolo_onnx.py +0 -0
  28. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/detector.py +0 -0
  29. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/face_train_dlib.py +0 -0
  30. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/filters.py +0 -0
  31. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/ml/pipeline.py +0 -0
  32. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/models/__init__.py +0 -0
  33. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/models/detection.py +0 -0
  34. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/models/zm.py +0 -0
  35. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/serve/__init__.py +0 -0
  36. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/serve/__main__.py +0 -0
  37. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/serve/app.py +0 -0
  38. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/serve/auth.py +0 -0
  39. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/__init__.py +0 -0
  40. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/__main__.py +0 -0
  41. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/_import_panel.py +0 -0
  42. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/_review_panel.py +0 -0
  43. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/_train_panel.py +0 -0
  44. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/app.py +0 -0
  45. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/assets/pyzm.png +0 -0
  46. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/auto_label.py +0 -0
  47. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/dataset.py +0 -0
  48. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/local_import.py +0 -0
  49. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/pipeline.py +0 -0
  50. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/trainer.py +0 -0
  51. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/train/verification.py +0 -0
  52. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/__init__.py +0 -0
  53. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/api.py +0 -0
  54. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/auth.py +0 -0
  55. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/db.py +0 -0
  56. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/media.py +0 -0
  57. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm/zm/shm.py +0 -0
  58. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm.egg-info/SOURCES.txt +0 -0
  59. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm.egg-info/dependency_links.txt +0 -0
  60. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm.egg-info/requires.txt +0 -0
  61. {pyzm-2.3.4 → pyzm-2.3.6}/pyzm.egg-info/top_level.txt +0 -0
  62. {pyzm-2.3.4 → pyzm-2.3.6}/setup.cfg +0 -0
  63. {pyzm-2.3.4 → pyzm-2.3.6}/setup.py +0 -0
  64. {pyzm-2.3.4 → pyzm-2.3.6}/tests/__init__.py +0 -0
  65. {pyzm-2.3.4 → pyzm-2.3.6}/tests/conftest.py +0 -0
  66. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_config.py +0 -0
  67. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_filters.py +0 -0
  68. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/__init__.py +0 -0
  69. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_birdnet.py +0 -0
  70. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_detector.py +0 -0
  71. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_filters.py +0 -0
  72. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_pipeline.py +0 -0
  73. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_pipeline_lazy.py +0 -0
  74. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml/test_yolo_onnx.py +0 -0
  75. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/__init__.py +0 -0
  76. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/conftest.py +0 -0
  77. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_basic_detection.py +0 -0
  78. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_disabled_models.py +0 -0
  79. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_edge_cases.py +0 -0
  80. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_filter_combinations.py +0 -0
  81. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_frame_strategies.py +0 -0
  82. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_from_dict.py +0 -0
  83. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_match_strategies.py +0 -0
  84. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_min_confidence.py +0 -0
  85. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_model_dimensions.py +0 -0
  86. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_model_discovery.py +0 -0
  87. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_multi_model.py +0 -0
  88. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_past_detections.py +0 -0
  89. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_pattern_filtering.py +0 -0
  90. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_pipeline_loading.py +0 -0
  91. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_pre_existing_labels.py +0 -0
  92. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_remote_serve.py +0 -0
  93. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_server_config.py +0 -0
  94. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_size_filtering.py +0 -0
  95. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_stream_config.py +0 -0
  96. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_ml_e2e/test_zone_filtering.py +0 -0
  97. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_models/__init__.py +0 -0
  98. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_models/test_detection.py +0 -0
  99. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_models/test_zm.py +0 -0
  100. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_serve/__init__.py +0 -0
  101. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_serve/test_app.py +0 -0
  102. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_serve/test_auth.py +0 -0
  103. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_serve/test_models_all.py +0 -0
  104. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/__init__.py +0 -0
  105. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_app_review.py +0 -0
  106. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_auto_label.py +0 -0
  107. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_dataset.py +0 -0
  108. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_local_import.py +0 -0
  109. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_trainer.py +0 -0
  110. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train/test_verification.py +0 -0
  111. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_train_pipeline.py +0 -0
  112. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm/__init__.py +0 -0
  113. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm/test_api.py +0 -0
  114. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm/test_auth.py +0 -0
  115. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm/test_client.py +0 -0
  116. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/__init__.py +0 -0
  117. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/conftest.py +0 -0
  118. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_auth.py +0 -0
  119. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_configs.py +0 -0
  120. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_db.py +0 -0
  121. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_detection.py +0 -0
  122. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_events.py +0 -0
  123. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_frames.py +0 -0
  124. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_monitors.py +0 -0
  125. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_states.py +0 -0
  126. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_system.py +0 -0
  127. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_write_events.py +0 -0
  128. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_write_monitors.py +0 -0
  129. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_write_notes.py +0 -0
  130. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_write_state.py +0 -0
  131. {pyzm-2.3.4 → pyzm-2.3.6}/tests/test_zm_e2e/test_zones.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyzm
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: ZoneMinder API, Logger and other base utilities for python programmers
5
5
  Home-page: https://github.com/pliablepixels/pyzm
6
6
  Author: Pliable Pixels
@@ -3,7 +3,7 @@
3
3
  v2: Typed configuration, clean APIs, proper result objects.
4
4
  """
5
5
 
6
- __version__ = "2.3.4"
6
+ __version__ = "2.3.6"
7
7
  VERSION = __version__
8
8
 
9
9
  from pyzm.client import ZMClient
@@ -22,7 +22,12 @@ import sys as _sys
22
22
  import time
23
23
  from typing import Any
24
24
 
25
- __all__ = ["ZMLogAdapter", "setup_zm_logging"]
25
+ __all__ = [
26
+ "ZMLogAdapter",
27
+ "setup_zm_logging",
28
+ "get_logpath",
29
+ "get_log_file",
30
+ ]
26
31
 
27
32
  # ZM level mapping: DBG=1, INF=0, WAR=-1, ERR=-2, FAT=-3
28
33
  _ZM_LEVELS: dict[str, int] = {"DBG": 1, "INF": 0, "WAR": -1, "ERR": -2, "FAT": -3}
@@ -467,11 +472,6 @@ def setup_zm_logging(
467
472
  "dump_console": os.environ.get("PYZM_DUMPCONSOLE"),
468
473
  }
469
474
 
470
- # Apply defaults for None values
471
- for key, val in defaults.items():
472
- if config.get(key) is None and val is not None:
473
- config[key] = val
474
-
475
475
  # Round 1 overrides (before DB read)
476
476
  for key in override:
477
477
  if override[key]:
@@ -648,3 +648,38 @@ def setup_zm_logging(
648
648
  pass
649
649
 
650
650
  return adapter
651
+
652
+
653
+ def get_logpath(conf_path: str = "/etc/zm") -> str:
654
+ """Return the resolved ZM log directory without initializing logging.
655
+
656
+ Precedence (matches setup_zm_logging):
657
+ PYZM_LOGPATH env var
658
+ > ZM_PATH_LOGS in /etc/zm/zm.conf and conf.d/*.conf
659
+ > /var/log/zm (default)
660
+
661
+ Safe to call from any process; does not touch the DB, open handlers,
662
+ or register signal handlers.
663
+ """
664
+ env = os.environ.get("PYZM_LOGPATH")
665
+ if env:
666
+ return env
667
+ conf_val = _read_zm_conf_full(conf_path).get("logpath")
668
+ if conf_val:
669
+ return conf_val
670
+ return "/var/log/zm"
671
+
672
+
673
+ def get_log_file() -> str | None:
674
+ """Return the file path pyzm is currently writing logs to, or None.
675
+
676
+ Returns None when file logging is disabled (``ZM_LOG_LEVEL_FILE``
677
+ set to off) or when :func:`setup_zm_logging` has not been called.
678
+ Reflects live state -- honors ``ZM_LOG_DEBUG_FILE`` overrides and
679
+ any post-SIGHUP rotation, since it reads ``baseFilename`` off the
680
+ actual ``WatchedFileHandler``.
681
+ """
682
+ for h in logging.getLogger("pyzm").handlers:
683
+ if isinstance(h, logging.handlers.WatchedFileHandler):
684
+ return h.baseFilename
685
+ return None
@@ -447,7 +447,7 @@ class StreamConfig(BaseModel):
447
447
  save_frames: bool = False
448
448
  save_frames_dir: str = "/tmp"
449
449
  delete_after_analyze: bool = False
450
- convert_snapshot_to_fid: bool = True
450
+ convert_snapshot_to_fid: bool = False
451
451
 
452
452
  @classmethod
453
453
  def from_dict(cls, d: dict[str, Any]) -> "StreamConfig":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyzm
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: ZoneMinder API, Logger and other base utilities for python programmers
5
5
  Home-page: https://github.com/pliablepixels/pyzm
6
6
  Author: Pliable Pixels
@@ -661,7 +661,7 @@ class TestStreamConfigVariants:
661
661
  sc = StreamConfig.from_dict({})
662
662
  assert sc.resize is None
663
663
  assert sc.frame_set == ["snapshot", "alarm", "1"]
664
- assert sc.convert_snapshot_to_fid is True
664
+ assert sc.convert_snapshot_to_fid is False
665
665
  assert sc.save_frames is False
666
666
 
667
667
  def test_download_bool_string(self):
@@ -0,0 +1,44 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import numpy as np
5
+ from pyzm.models.config import ModelConfig, ModelFramework, ModelType
6
+
7
+
8
+ class TestFaceDlibLockRelease(unittest.TestCase):
9
+ """H4: Lock must be released even if face_recognition raises."""
10
+
11
+ def test_lock_released_on_exception(self):
12
+ # Inject mock modules for the lazy imports inside detect().
13
+ # Use patch.dict so cleanup restores sys.modules and the mocks
14
+ # don't leak into other tests that genuinely need cv2.
15
+ mock_cv2 = MagicMock()
16
+ mock_fr = MagicMock()
17
+ mock_imutils = MagicMock()
18
+
19
+ with patch.dict("sys.modules", {
20
+ "cv2": mock_cv2,
21
+ "imutils": mock_imutils,
22
+ "face_recognition": mock_fr,
23
+ }):
24
+ from pyzm.ml.backends.face_dlib import FaceDlibBackend
25
+
26
+ config = ModelConfig(
27
+ type=ModelType.FACE,
28
+ framework=ModelFramework.FACE_DLIB,
29
+ known_faces_dir="/tmp/known",
30
+ )
31
+ backend = FaceDlibBackend(config)
32
+ backend._knn = MagicMock() # pretend loaded
33
+ backend._auto_lock = True
34
+ backend.acquire_lock = MagicMock()
35
+ backend.release_lock = MagicMock()
36
+
37
+ mock_fr.face_locations.side_effect = RuntimeError("GPU OOM")
38
+
39
+ dummy = np.zeros((100, 100, 3), dtype=np.uint8)
40
+ with self.assertRaises(RuntimeError):
41
+ backend.detect(dummy)
42
+
43
+ backend.acquire_lock.assert_called_once()
44
+ backend.release_lock.assert_called_once()
@@ -14,6 +14,7 @@ from pyzm.log import (
14
14
  _read_zm_conf_full, _zm_config_to_handler_level, _ZM_OFF,
15
15
  _ZMDBHandler, _ZMFileFormatter, _ZMSyslogFormatter,
16
16
  ZMLogAdapter, setup_zm_logging,
17
+ get_logpath, get_log_file,
17
18
  )
18
19
 
19
20
 
@@ -668,6 +669,73 @@ class TestSetupZmLogging:
668
669
  assert adapter._config["log_debug_target"] == "zmc_m1" # DB value kept (no override)
669
670
  adapter.close()
670
671
 
672
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
673
+ @patch("pyzm.log._read_zm_conf_full", return_value={
674
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
675
+ "dbname": "zm", "webuser": "zmuser", "webgroup": "zmgroup",
676
+ "logpath": "/var/log/zoneminder",
677
+ })
678
+ @patch("pyzm.log._signal.signal")
679
+ def test_conf_logpath_honored(self, mock_sig, mock_conf, mock_db, monkeypatch):
680
+ """ZM_PATH_LOGS from zm.conf must flow through when no env/override is set.
681
+
682
+ Regression test for #45 -- the first defaults loop used to populate
683
+ config['logpath'] with '/var/log/zm' before the conf was read, causing
684
+ the conf value to be silently dropped.
685
+ """
686
+ monkeypatch.delenv("PYZM_LOGPATH", raising=False)
687
+ monkeypatch.delenv("PYZM_WEBUSER", raising=False)
688
+ monkeypatch.delenv("PYZM_WEBGROUP", raising=False)
689
+ adapter = setup_zm_logging(name="test_conf_logpath", override={
690
+ "log_level_file": _ZM_OFF,
691
+ "log_level_db": _ZM_OFF,
692
+ "log_level_syslog": _ZM_OFF,
693
+ })
694
+ assert adapter._config["logpath"] == "/var/log/zoneminder"
695
+ assert adapter._config["webuser"] == "zmuser"
696
+ assert adapter._config["webgroup"] == "zmgroup"
697
+ adapter.close()
698
+
699
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
700
+ @patch("pyzm.log._read_zm_conf_full", return_value={
701
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
702
+ "dbname": "zm", "webuser": "zmuser", "webgroup": "zmgroup",
703
+ "logpath": "/var/log/zoneminder",
704
+ })
705
+ @patch("pyzm.log._signal.signal")
706
+ def test_env_overrides_conf_logpath(self, mock_sig, mock_conf, mock_db, monkeypatch):
707
+ """PYZM_LOGPATH env var still wins over the conf-file value."""
708
+ monkeypatch.setenv("PYZM_LOGPATH", "/env/logs")
709
+ adapter = setup_zm_logging(name="test_env_logpath", override={
710
+ "log_level_file": _ZM_OFF,
711
+ "log_level_db": _ZM_OFF,
712
+ "log_level_syslog": _ZM_OFF,
713
+ })
714
+ assert adapter._config["logpath"] == "/env/logs"
715
+ adapter.close()
716
+
717
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
718
+ @patch("pyzm.log._read_zm_conf_full", return_value={
719
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
720
+ "dbname": "zm", "webuser": None, "webgroup": None,
721
+ "logpath": None,
722
+ })
723
+ @patch("pyzm.log._signal.signal")
724
+ def test_final_defaults_when_no_source(self, mock_sig, mock_conf, mock_db, monkeypatch):
725
+ """Hard-coded defaults apply only when env, conf, and override are all empty."""
726
+ monkeypatch.delenv("PYZM_LOGPATH", raising=False)
727
+ monkeypatch.delenv("PYZM_WEBUSER", raising=False)
728
+ monkeypatch.delenv("PYZM_WEBGROUP", raising=False)
729
+ adapter = setup_zm_logging(name="test_default_logpath", override={
730
+ "log_level_file": _ZM_OFF,
731
+ "log_level_db": _ZM_OFF,
732
+ "log_level_syslog": _ZM_OFF,
733
+ })
734
+ assert adapter._config["logpath"] == "/var/log/zm"
735
+ assert adapter._config["webuser"] == "www-data"
736
+ assert adapter._config["webgroup"] == "www-data"
737
+ adapter.close()
738
+
671
739
  @patch("pyzm.log._read_zm_db_log_config", return_value={})
672
740
  @patch("pyzm.log._read_zm_conf_full", return_value={
673
741
  "dbuser": "u", "dbpassword": "p", "dbhost": "h",
@@ -933,3 +1001,122 @@ class TestFileFormatAllLevels:
933
1001
  for lvl in range(1, 10):
934
1002
  result = self._format(logging.DEBUG, zm_debug_level=lvl)
935
1003
  assert f"].DB{lvl} [" in result
1004
+
1005
+
1006
+ # ===================================================================
1007
+ # TestGetLogpath
1008
+ # ===================================================================
1009
+
1010
+ class TestGetLogpath:
1011
+ """Tests for the public get_logpath() helper (issue #46)."""
1012
+
1013
+ def test_env_var_wins(self, tmp_path, monkeypatch):
1014
+ """PYZM_LOGPATH beats conf-file value."""
1015
+ (tmp_path / "zm.conf").write_text("ZM_PATH_LOGS=/conf/path\n")
1016
+ monkeypatch.setenv("PYZM_LOGPATH", "/env/path")
1017
+ assert get_logpath(str(tmp_path)) == "/env/path"
1018
+
1019
+ def test_conf_file_wins_over_default(self, tmp_path, monkeypatch):
1020
+ """ZM_PATH_LOGS in zm.conf is used when env is unset."""
1021
+ (tmp_path / "zm.conf").write_text("ZM_PATH_LOGS=/conf/path\n")
1022
+ monkeypatch.delenv("PYZM_LOGPATH", raising=False)
1023
+ assert get_logpath(str(tmp_path)) == "/conf/path"
1024
+
1025
+ def test_default_when_nothing_set(self, tmp_path, monkeypatch):
1026
+ """Falls back to /var/log/zm when env and conf are silent."""
1027
+ (tmp_path / "zm.conf").write_text("")
1028
+ monkeypatch.delenv("PYZM_LOGPATH", raising=False)
1029
+ assert get_logpath(str(tmp_path)) == "/var/log/zm"
1030
+
1031
+ def test_does_not_require_setup(self, tmp_path, monkeypatch):
1032
+ """get_logpath() works standalone -- no setup_zm_logging needed."""
1033
+ (tmp_path / "zm.conf").write_text("ZM_PATH_LOGS=/standalone/path\n")
1034
+ monkeypatch.delenv("PYZM_LOGPATH", raising=False)
1035
+ # Wipe any pyzm logger handlers to prove we don't peek at them
1036
+ logging.getLogger("pyzm").handlers.clear()
1037
+ assert get_logpath(str(tmp_path)) == "/standalone/path"
1038
+
1039
+
1040
+ # ===================================================================
1041
+ # TestGetLogFile
1042
+ # ===================================================================
1043
+
1044
+ class TestGetLogFile:
1045
+ """Tests for the public get_log_file() helper (issue #46)."""
1046
+
1047
+ def setup_method(self):
1048
+ # Each test starts from a clean pyzm logger
1049
+ logging.getLogger("pyzm").handlers.clear()
1050
+
1051
+ def teardown_method(self):
1052
+ logging.getLogger("pyzm").handlers.clear()
1053
+
1054
+ def test_returns_none_when_no_handler(self):
1055
+ """Before setup_zm_logging -- no file handler attached -- returns None."""
1056
+ assert get_log_file() is None
1057
+
1058
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
1059
+ @patch("pyzm.log._read_zm_conf_full", return_value={
1060
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
1061
+ "dbname": "zm", "webuser": "www", "webgroup": "www",
1062
+ "logpath": None, # forces final-default fallback
1063
+ })
1064
+ @patch("pyzm.log._signal.signal")
1065
+ def test_returns_path_after_setup(self, mock_sig, mock_conf, mock_db, tmp_path):
1066
+ """Returns the WatchedFileHandler's open file path after setup."""
1067
+ adapter = setup_zm_logging(name="zmesdetect_m1", override={
1068
+ "logpath": str(tmp_path),
1069
+ "log_level_file": 0,
1070
+ "log_level_db": _ZM_OFF,
1071
+ "log_level_syslog": _ZM_OFF,
1072
+ })
1073
+ try:
1074
+ assert get_log_file() == str(tmp_path / "zmesdetect_m1.log")
1075
+ finally:
1076
+ adapter.close()
1077
+
1078
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
1079
+ @patch("pyzm.log._read_zm_conf_full", return_value={
1080
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
1081
+ "dbname": "zm", "webuser": "www", "webgroup": "www",
1082
+ "logpath": "/tmp",
1083
+ })
1084
+ @patch("pyzm.log._signal.signal")
1085
+ def test_returns_none_when_file_logging_disabled(
1086
+ self, mock_sig, mock_conf, mock_db,
1087
+ ):
1088
+ """File handler isn't attached when log_level_file == _ZM_OFF."""
1089
+ adapter = setup_zm_logging(name="zm_nofile", override={
1090
+ "log_level_file": _ZM_OFF,
1091
+ "log_level_db": _ZM_OFF,
1092
+ "log_level_syslog": _ZM_OFF,
1093
+ })
1094
+ try:
1095
+ assert get_log_file() is None
1096
+ finally:
1097
+ adapter.close()
1098
+
1099
+ @patch("pyzm.log._read_zm_db_log_config", return_value={})
1100
+ @patch("pyzm.log._read_zm_conf_full", return_value={
1101
+ "dbuser": "u", "dbpassword": "p", "dbhost": "h",
1102
+ "dbname": "zm", "webuser": "www", "webgroup": "www",
1103
+ "logpath": "/should/be/ignored",
1104
+ })
1105
+ @patch("pyzm.log._signal.signal")
1106
+ def test_reflects_debug_file_override(
1107
+ self, mock_sig, mock_conf, mock_db, tmp_path,
1108
+ ):
1109
+ """ZM_LOG_DEBUG_FILE override is visible in get_log_file()."""
1110
+ debug_file = tmp_path / "custom-debug.log"
1111
+ adapter = setup_zm_logging(name="zm_dbg", override={
1112
+ "log_debug": 1,
1113
+ "log_level_debug": 5,
1114
+ "log_debug_file": str(debug_file),
1115
+ "log_level_file": 0,
1116
+ "log_level_db": _ZM_OFF,
1117
+ "log_level_syslog": _ZM_OFF,
1118
+ })
1119
+ try:
1120
+ assert get_log_file() == str(debug_file)
1121
+ finally:
1122
+ adapter.close()
@@ -604,7 +604,7 @@ class TestStreamConfig:
604
604
  assert sc.sleep_between_attempts == 3
605
605
  assert sc.save_frames is False
606
606
  assert sc.save_frames_dir == "/tmp"
607
- assert sc.convert_snapshot_to_fid is True
607
+ assert sc.convert_snapshot_to_fid is False
608
608
 
609
609
  def test_all_fields_override(self):
610
610
  sc = StreamConfig(
@@ -1,42 +0,0 @@
1
- import sys
2
- import unittest
3
- from unittest.mock import MagicMock
4
-
5
- import numpy as np
6
- from pyzm.models.config import ModelConfig, ModelFramework, ModelType
7
-
8
-
9
- class TestFaceDlibLockRelease(unittest.TestCase):
10
- """H4: Lock must be released even if face_recognition raises."""
11
-
12
- def test_lock_released_on_exception(self):
13
- # Inject mock modules for the lazy imports inside detect()
14
- mock_cv2 = MagicMock()
15
- mock_fr = MagicMock()
16
- mock_imutils = MagicMock()
17
-
18
- sys.modules.setdefault("cv2", mock_cv2)
19
- sys.modules.setdefault("imutils", mock_imutils)
20
- sys.modules.setdefault("face_recognition", mock_fr)
21
-
22
- from pyzm.ml.backends.face_dlib import FaceDlibBackend
23
-
24
- config = ModelConfig(
25
- type=ModelType.FACE,
26
- framework=ModelFramework.FACE_DLIB,
27
- known_faces_dir="/tmp/known",
28
- )
29
- backend = FaceDlibBackend(config)
30
- backend._knn = MagicMock() # pretend loaded
31
- backend._auto_lock = True
32
- backend.acquire_lock = MagicMock()
33
- backend.release_lock = MagicMock()
34
-
35
- mock_fr.face_locations.side_effect = RuntimeError("GPU OOM")
36
-
37
- dummy = np.zeros((100, 100, 3), dtype=np.uint8)
38
- with self.assertRaises(RuntimeError):
39
- backend.detect(dummy)
40
-
41
- backend.acquire_lock.assert_called_once()
42
- backend.release_lock.assert_called_once()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes