seedsyncarr 1.1.1__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 (181) hide show
  1. seedsyncarr-1.1.1/.gitignore +29 -0
  2. seedsyncarr-1.1.1/PKG-INFO +26 -0
  3. seedsyncarr-1.1.1/__init__.py +0 -0
  4. seedsyncarr-1.1.1/common/__init__.py +13 -0
  5. seedsyncarr-1.1.1/common/app_process.py +166 -0
  6. seedsyncarr-1.1.1/common/bounded_ordered_set.py +203 -0
  7. seedsyncarr-1.1.1/common/config.py +596 -0
  8. seedsyncarr-1.1.1/common/constants.py +12 -0
  9. seedsyncarr-1.1.1/common/context.py +70 -0
  10. seedsyncarr-1.1.1/common/encryption.py +160 -0
  11. seedsyncarr-1.1.1/common/error.py +18 -0
  12. seedsyncarr-1.1.1/common/job.py +89 -0
  13. seedsyncarr-1.1.1/common/localization.py +7 -0
  14. seedsyncarr-1.1.1/common/multiprocessing_logger.py +87 -0
  15. seedsyncarr-1.1.1/common/persist.py +59 -0
  16. seedsyncarr-1.1.1/common/status.py +189 -0
  17. seedsyncarr-1.1.1/common/types.py +14 -0
  18. seedsyncarr-1.1.1/controller/__init__.py +11 -0
  19. seedsyncarr-1.1.1/controller/auto_queue.py +375 -0
  20. seedsyncarr-1.1.1/controller/controller.py +1115 -0
  21. seedsyncarr-1.1.1/controller/controller_job.py +29 -0
  22. seedsyncarr-1.1.1/controller/controller_persist.py +248 -0
  23. seedsyncarr-1.1.1/controller/delete/__init__.py +1 -0
  24. seedsyncarr-1.1.1/controller/delete/delete_process.py +50 -0
  25. seedsyncarr-1.1.1/controller/extract/__init__.py +3 -0
  26. seedsyncarr-1.1.1/controller/extract/dispatch.py +228 -0
  27. seedsyncarr-1.1.1/controller/extract/extract.py +61 -0
  28. seedsyncarr-1.1.1/controller/extract/extract_process.py +124 -0
  29. seedsyncarr-1.1.1/controller/file_operation_manager.py +228 -0
  30. seedsyncarr-1.1.1/controller/lftp_manager.py +157 -0
  31. seedsyncarr-1.1.1/controller/memory_monitor.py +256 -0
  32. seedsyncarr-1.1.1/controller/model_builder.py +581 -0
  33. seedsyncarr-1.1.1/controller/scan/__init__.py +4 -0
  34. seedsyncarr-1.1.1/controller/scan/active_scanner.py +52 -0
  35. seedsyncarr-1.1.1/controller/scan/local_scanner.py +41 -0
  36. seedsyncarr-1.1.1/controller/scan/remote_scanner.py +179 -0
  37. seedsyncarr-1.1.1/controller/scan/scanner_process.py +131 -0
  38. seedsyncarr-1.1.1/controller/scan_manager.py +167 -0
  39. seedsyncarr-1.1.1/controller/webhook_manager.py +92 -0
  40. seedsyncarr-1.1.1/docs/arr-setup.md +109 -0
  41. seedsyncarr-1.1.1/docs/configuration.md +81 -0
  42. seedsyncarr-1.1.1/docs/faq.md +52 -0
  43. seedsyncarr-1.1.1/docs/images/favicon.png +0 -0
  44. seedsyncarr-1.1.1/docs/images/logo.png +0 -0
  45. seedsyncarr-1.1.1/docs/images/screenshot-dashboard.png +0 -0
  46. seedsyncarr-1.1.1/docs/index.md +43 -0
  47. seedsyncarr-1.1.1/docs/install.md +67 -0
  48. seedsyncarr-1.1.1/lftp/__init__.py +14 -0
  49. seedsyncarr-1.1.1/lftp/job_status.py +99 -0
  50. seedsyncarr-1.1.1/lftp/job_status_parser.py +749 -0
  51. seedsyncarr-1.1.1/lftp/lftp.py +387 -0
  52. seedsyncarr-1.1.1/mkdocs.yml +69 -0
  53. seedsyncarr-1.1.1/model/__init__.py +3 -0
  54. seedsyncarr-1.1.1/model/diff.py +79 -0
  55. seedsyncarr-1.1.1/model/file.py +316 -0
  56. seedsyncarr-1.1.1/model/model.py +136 -0
  57. seedsyncarr-1.1.1/poetry.lock +1550 -0
  58. seedsyncarr-1.1.1/pyproject.toml +101 -0
  59. seedsyncarr-1.1.1/scan_fs.py +40 -0
  60. seedsyncarr-1.1.1/seedsyncarr.py +518 -0
  61. seedsyncarr-1.1.1/ssh/__init__.py +1 -0
  62. seedsyncarr-1.1.1/ssh/sshcp.py +208 -0
  63. seedsyncarr-1.1.1/system/__init__.py +2 -0
  64. seedsyncarr-1.1.1/system/file.py +86 -0
  65. seedsyncarr-1.1.1/system/scanner.py +216 -0
  66. seedsyncarr-1.1.1/tests/__init__.py +0 -0
  67. seedsyncarr-1.1.1/tests/conftest.py +106 -0
  68. seedsyncarr-1.1.1/tests/integration/__init__.py +0 -0
  69. seedsyncarr-1.1.1/tests/integration/test_controller/__init__.py +0 -0
  70. seedsyncarr-1.1.1/tests/integration/test_controller/test_controller.py +2424 -0
  71. seedsyncarr-1.1.1/tests/integration/test_controller/test_extract/__init__.py +0 -0
  72. seedsyncarr-1.1.1/tests/integration/test_controller/test_extract/test_extract.py +209 -0
  73. seedsyncarr-1.1.1/tests/integration/test_lftp/__init__.py +0 -0
  74. seedsyncarr-1.1.1/tests/integration/test_lftp/test_lftp.py +164 -0
  75. seedsyncarr-1.1.1/tests/integration/test_web/__init__.py +0 -0
  76. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/__init__.py +0 -0
  77. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_auto_queue.py +140 -0
  78. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_config.py +83 -0
  79. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_controller.py +182 -0
  80. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_server.py +10 -0
  81. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_status.py +11 -0
  82. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_stream_log.py +42 -0
  83. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_stream_model.py +79 -0
  84. seedsyncarr-1.1.1/tests/integration/test_web/test_handler/test_stream_status.py +52 -0
  85. seedsyncarr-1.1.1/tests/integration/test_web/test_web_app.py +63 -0
  86. seedsyncarr-1.1.1/tests/unittests/__init__.py +0 -0
  87. seedsyncarr-1.1.1/tests/unittests/test_common/__init__.py +0 -0
  88. seedsyncarr-1.1.1/tests/unittests/test_common/test_app_process.py +214 -0
  89. seedsyncarr-1.1.1/tests/unittests/test_common/test_bounded_ordered_set.py +303 -0
  90. seedsyncarr-1.1.1/tests/unittests/test_common/test_config.py +1123 -0
  91. seedsyncarr-1.1.1/tests/unittests/test_common/test_constants.py +46 -0
  92. seedsyncarr-1.1.1/tests/unittests/test_common/test_context.py +118 -0
  93. seedsyncarr-1.1.1/tests/unittests/test_common/test_encryption.py +131 -0
  94. seedsyncarr-1.1.1/tests/unittests/test_common/test_error.py +61 -0
  95. seedsyncarr-1.1.1/tests/unittests/test_common/test_job.py +42 -0
  96. seedsyncarr-1.1.1/tests/unittests/test_common/test_localization.py +48 -0
  97. seedsyncarr-1.1.1/tests/unittests/test_common/test_multiprocessing_logger.py +154 -0
  98. seedsyncarr-1.1.1/tests/unittests/test_common/test_persist.py +99 -0
  99. seedsyncarr-1.1.1/tests/unittests/test_common/test_status.py +239 -0
  100. seedsyncarr-1.1.1/tests/unittests/test_common/test_types.py +77 -0
  101. seedsyncarr-1.1.1/tests/unittests/test_controller/__init__.py +0 -0
  102. seedsyncarr-1.1.1/tests/unittests/test_controller/test_auto_delete.py +495 -0
  103. seedsyncarr-1.1.1/tests/unittests/test_controller/test_auto_queue.py +1706 -0
  104. seedsyncarr-1.1.1/tests/unittests/test_controller/test_controller.py +169 -0
  105. seedsyncarr-1.1.1/tests/unittests/test_controller/test_controller_job.py +40 -0
  106. seedsyncarr-1.1.1/tests/unittests/test_controller/test_controller_persist.py +281 -0
  107. seedsyncarr-1.1.1/tests/unittests/test_controller/test_controller_unit.py +1157 -0
  108. seedsyncarr-1.1.1/tests/unittests/test_controller/test_extract/__init__.py +0 -0
  109. seedsyncarr-1.1.1/tests/unittests/test_controller/test_extract/test_dispatch.py +860 -0
  110. seedsyncarr-1.1.1/tests/unittests/test_controller/test_extract/test_extract_process.py +278 -0
  111. seedsyncarr-1.1.1/tests/unittests/test_controller/test_file_operation_manager.py +441 -0
  112. seedsyncarr-1.1.1/tests/unittests/test_controller/test_lftp_manager.py +235 -0
  113. seedsyncarr-1.1.1/tests/unittests/test_controller/test_memory_monitor.py +164 -0
  114. seedsyncarr-1.1.1/tests/unittests/test_controller/test_model_builder.py +1467 -0
  115. seedsyncarr-1.1.1/tests/unittests/test_controller/test_scan/__init__.py +0 -0
  116. seedsyncarr-1.1.1/tests/unittests/test_controller/test_scan/test_local_scanner.py +110 -0
  117. seedsyncarr-1.1.1/tests/unittests/test_controller/test_scan/test_remote_scanner.py +847 -0
  118. seedsyncarr-1.1.1/tests/unittests/test_controller/test_scan/test_scanner_process.py +193 -0
  119. seedsyncarr-1.1.1/tests/unittests/test_controller/test_scan_manager.py +246 -0
  120. seedsyncarr-1.1.1/tests/unittests/test_controller/test_webhook_manager.py +115 -0
  121. seedsyncarr-1.1.1/tests/unittests/test_lftp/__init__.py +0 -0
  122. seedsyncarr-1.1.1/tests/unittests/test_lftp/test_job_status.py +232 -0
  123. seedsyncarr-1.1.1/tests/unittests/test_lftp/test_job_status_parser.py +1464 -0
  124. seedsyncarr-1.1.1/tests/unittests/test_lftp/test_job_status_parser_components.py +516 -0
  125. seedsyncarr-1.1.1/tests/unittests/test_lftp/test_lftp.py +752 -0
  126. seedsyncarr-1.1.1/tests/unittests/test_model/__init__.py +0 -0
  127. seedsyncarr-1.1.1/tests/unittests/test_model/test_diff.py +115 -0
  128. seedsyncarr-1.1.1/tests/unittests/test_model/test_file.py +253 -0
  129. seedsyncarr-1.1.1/tests/unittests/test_model/test_model.py +166 -0
  130. seedsyncarr-1.1.1/tests/unittests/test_seedsyncarr.py +408 -0
  131. seedsyncarr-1.1.1/tests/unittests/test_ssh/__init__.py +0 -0
  132. seedsyncarr-1.1.1/tests/unittests/test_ssh/test_sshcp.py +225 -0
  133. seedsyncarr-1.1.1/tests/unittests/test_system/__init__.py +0 -0
  134. seedsyncarr-1.1.1/tests/unittests/test_system/test_file.py +82 -0
  135. seedsyncarr-1.1.1/tests/unittests/test_system/test_scanner.py +659 -0
  136. seedsyncarr-1.1.1/tests/unittests/test_web/__init__.py +0 -0
  137. seedsyncarr-1.1.1/tests/unittests/test_web/test_auth.py +242 -0
  138. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/__init__.py +0 -0
  139. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_auto_queue_handler.py +109 -0
  140. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_config_handler.py +369 -0
  141. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_controller_handler.py +834 -0
  142. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_server_handler.py +52 -0
  143. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_status_handler.py +28 -0
  144. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_stream_heartbeat.py +115 -0
  145. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_stream_log.py +219 -0
  146. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_stream_model_handler.py +144 -0
  147. seedsyncarr-1.1.1/tests/unittests/test_web/test_handler/test_stream_status_handler.py +95 -0
  148. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/__init__.py +0 -0
  149. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize.py +15 -0
  150. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize_auto_queue.py +37 -0
  151. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize_config.py +212 -0
  152. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize_log_record.py +291 -0
  153. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize_model.py +332 -0
  154. seedsyncarr-1.1.1/tests/unittests/test_web/test_serialize/test_serialize_status.py +145 -0
  155. seedsyncarr-1.1.1/tests/unittests/test_web/test_stream_queue.py +91 -0
  156. seedsyncarr-1.1.1/tests/unittests/test_web/test_web_app.py +228 -0
  157. seedsyncarr-1.1.1/tests/unittests/test_web/test_webhook_handler.py +298 -0
  158. seedsyncarr-1.1.1/tests/utils.py +24 -0
  159. seedsyncarr-1.1.1/web/__init__.py +3 -0
  160. seedsyncarr-1.1.1/web/handler/__init__.py +0 -0
  161. seedsyncarr-1.1.1/web/handler/auto_queue.py +50 -0
  162. seedsyncarr-1.1.1/web/handler/config.py +177 -0
  163. seedsyncarr-1.1.1/web/handler/controller.py +483 -0
  164. seedsyncarr-1.1.1/web/handler/server.py +34 -0
  165. seedsyncarr-1.1.1/web/handler/status.py +17 -0
  166. seedsyncarr-1.1.1/web/handler/stream_heartbeat.py +58 -0
  167. seedsyncarr-1.1.1/web/handler/stream_log.py +116 -0
  168. seedsyncarr-1.1.1/web/handler/stream_model.py +71 -0
  169. seedsyncarr-1.1.1/web/handler/stream_status.py +47 -0
  170. seedsyncarr-1.1.1/web/handler/webhook.py +193 -0
  171. seedsyncarr-1.1.1/web/serialize/__init__.py +6 -0
  172. seedsyncarr-1.1.1/web/serialize/serialize.py +13 -0
  173. seedsyncarr-1.1.1/web/serialize/serialize_auto_queue.py +17 -0
  174. seedsyncarr-1.1.1/web/serialize/serialize_config.py +38 -0
  175. seedsyncarr-1.1.1/web/serialize/serialize_log_record.py +89 -0
  176. seedsyncarr-1.1.1/web/serialize/serialize_model.py +110 -0
  177. seedsyncarr-1.1.1/web/serialize/serialize_status.py +68 -0
  178. seedsyncarr-1.1.1/web/utils.py +95 -0
  179. seedsyncarr-1.1.1/web/web_app.py +297 -0
  180. seedsyncarr-1.1.1/web/web_app_builder.py +62 -0
  181. seedsyncarr-1.1.1/web/web_app_job.py +79 -0
@@ -0,0 +1,29 @@
1
+ .idea
2
+ *.pyc
3
+ /build
4
+ .venv
5
+ src/python/build
6
+ src/python/site
7
+ htmlcov/
8
+ .coverage
9
+ node_modules/
10
+ __pycache__/
11
+ src/python/.pytest_cache/
12
+ src/python/htmlcov/
13
+ src/angular/dist/
14
+ .DS_Store
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+
18
+ # SSH private keys
19
+ id_rsa
20
+ *.pem
21
+ # AI tooling (local only)
22
+ .agents/
23
+ .aidesigner/*
24
+ !.aidesigner/.gitkeep
25
+ .claude/
26
+ .mcp.json
27
+ .planning/tmp/
28
+ docs/superpowers/
29
+ shield-claude-skill/
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: seedsyncarr
3
+ Version: 1.1.1
4
+ Summary: Fast file syncing from remote servers with a web UI, powered by LFTP
5
+ Author: thejuran
6
+ Requires-Python: <3.13,>=3.11
7
+ Requires-Dist: bottle>=0.13.4
8
+ Requires-Dist: cryptography<47,>=44.0.0
9
+ Requires-Dist: paste>=3.10.1
10
+ Requires-Dist: patool>=4.0.3
11
+ Requires-Dist: pexpect>=4.9.0
12
+ Requires-Dist: pytz>=2025.2
13
+ Requires-Dist: requests>=2.33.0
14
+ Requires-Dist: tblib>=3.2.2
15
+ Requires-Dist: timeout-decorator>=0.5.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: mkdocs-material>=9.7.1; extra == 'dev'
18
+ Requires-Dist: mkdocs>=1.6.1; extra == 'dev'
19
+ Requires-Dist: parameterized>=0.9.0; extra == 'dev'
20
+ Requires-Dist: pyinstaller>=6.0.0; extra == 'dev'
21
+ Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
22
+ Requires-Dist: pytest-timeout>=2.3.1; extra == 'dev'
23
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
24
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
25
+ Requires-Dist: testfixtures>=11.0.0; extra == 'dev'
26
+ Requires-Dist: webtest>=3.0.7; extra == 'dev'
File without changes
@@ -0,0 +1,13 @@
1
+ from .types import overrides as overrides
2
+ from .job import Job as Job
3
+ from .context import Context as Context, Args as Args
4
+ from .error import AppError as AppError, ServiceExit as ServiceExit, ServiceRestart as ServiceRestart
5
+ from .encryption import EncryptionError as EncryptionError, DecryptionError as DecryptionError
6
+ from .constants import Constants as Constants
7
+ from .config import Config as Config, ConfigError as ConfigError
8
+ from .persist import Persist as Persist, PersistError as PersistError, Serializable as Serializable
9
+ from .localization import Localization as Localization
10
+ from .multiprocessing_logger import MultiprocessingLogger as MultiprocessingLogger
11
+ from .status import Status as Status, IStatusListener as IStatusListener, StatusComponent as StatusComponent, IStatusComponentListener as IStatusComponentListener
12
+ from .app_process import AppProcess as AppProcess, AppOneShotProcess as AppOneShotProcess
13
+ from .bounded_ordered_set import BoundedOrderedSet as BoundedOrderedSet
@@ -0,0 +1,166 @@
1
+ import logging
2
+ import sys
3
+ import time
4
+ from abc import abstractmethod
5
+ from multiprocessing import Process, Queue, Event
6
+ import queue
7
+ import signal
8
+ import threading
9
+ from datetime import datetime
10
+
11
+ import tblib.pickling_support
12
+
13
+ from common import overrides, ServiceExit, MultiprocessingLogger
14
+
15
+ tblib.pickling_support.install()
16
+
17
+ class ExceptionWrapper:
18
+ """
19
+ An exception wrapper that works across processes
20
+ Source: https://stackoverflow.com/a/26096355/8571324
21
+ """
22
+ def __init__(self, ee):
23
+ self.ee = ee
24
+ __, __, self.tb = sys.exc_info()
25
+
26
+ def re_raise(self):
27
+ raise self.ee.with_traceback(self.tb)
28
+
29
+ class AppProcess(Process):
30
+ """
31
+ Process with some additional functionality and fixes
32
+ * Support for a multiprocessing logger
33
+ * Removes signals to prevent join problems
34
+ * Propagates exceptions to owner process
35
+ * Safe terminate with timeout, followed by force terminate
36
+ """
37
+
38
+ # Timeout before process is force terminated
39
+ __DEFAULT_TERMINATE_TIMEOUT_MS = 1000
40
+
41
+ def __init__(self, name: str):
42
+ self.__name = name
43
+ super().__init__(name=self.__name)
44
+
45
+ self.mp_logger = None
46
+ self.logger = logging.getLogger(self.__name)
47
+ self.__exception_queue = Queue()
48
+ self._terminate = Event()
49
+
50
+ def set_multiprocessing_logger(self, mp_logger: MultiprocessingLogger):
51
+ self.mp_logger = mp_logger
52
+
53
+ @overrides(Process)
54
+ def run(self):
55
+ # Replace the signal handlers that may have been set by main process to
56
+ # default handlers. Having non-default handlers in subprocesses causes
57
+ # a deadlock when attempting to join the process
58
+ # Info: https://stackoverflow.com/a/631605
59
+
60
+ # NOTE: There is a minuscule chance of deadlock if a signal is received
61
+ # between start of the method and these resets.
62
+ # The ideal solution is to remove the signal before the process is
63
+ # started. Unfortunately that's difficult to do here because the
64
+ # subprocess is started from a job thread, and python doesn't
65
+ # allow setting signals from outside the main thread.
66
+ # So we accept this risk for the quick and easy solution here
67
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
68
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
69
+
70
+ # Set the thread name for convenience
71
+ threading.current_thread().name = self.__name
72
+
73
+ # Configure the logger for this process
74
+ if self.mp_logger:
75
+ self.logger = self.mp_logger.get_process_safe_logger().getChild(self.__name)
76
+
77
+ self.logger.debug("Started process")
78
+
79
+ self.run_init()
80
+
81
+ try:
82
+ while not self._terminate.is_set():
83
+ self.run_loop()
84
+ self.logger.debug("Process received terminate flag")
85
+ except ServiceExit:
86
+ self.logger.debug("Process received a ServiceExit")
87
+ except Exception as e:
88
+ self.logger.debug("Process caught an exception")
89
+ self.__exception_queue.put(ExceptionWrapper(e))
90
+ raise
91
+ finally:
92
+ self.run_cleanup()
93
+
94
+ self.logger.debug("Exiting process")
95
+
96
+ @overrides(Process)
97
+ def terminate(self):
98
+ # Send a terminate signal, and force terminate after a timeout
99
+ self._terminate.set()
100
+
101
+ def elapsed_ms(start):
102
+ delta_in_s = (datetime.now() - start).total_seconds()
103
+ delta_in_ms = int(delta_in_s * 1000)
104
+ return delta_in_ms
105
+
106
+ timestamp_start = datetime.now()
107
+ while self.is_alive() and \
108
+ elapsed_ms(timestamp_start) < AppProcess.__DEFAULT_TERMINATE_TIMEOUT_MS:
109
+ time.sleep(0.01) # 10ms polling interval to avoid CPU spin
110
+
111
+ super().terminate()
112
+
113
+ def propagate_exception(self):
114
+ """
115
+ Raises any exception that was caught by the process
116
+ """
117
+ try:
118
+ exc = self.__exception_queue.get(block=False)
119
+ exc.re_raise()
120
+ except queue.Empty:
121
+ pass
122
+
123
+ @abstractmethod
124
+ def run_init(self):
125
+ """
126
+ Called once before the run loop
127
+ """
128
+ pass
129
+
130
+ @abstractmethod
131
+ def run_cleanup(self):
132
+ """
133
+ Called once before cleanup
134
+ """
135
+ pass
136
+
137
+ @abstractmethod
138
+ def run_loop(self):
139
+ """
140
+ Process behaviour should be implemented here.
141
+ This function is repeatedly called until process exits.
142
+ The check for graceful shutdown is performed between the loop iterations,
143
+ so try to limit the run time for this method.
144
+ """
145
+ pass
146
+
147
+ class AppOneShotProcess(AppProcess):
148
+ """
149
+ App process that runs only once and then exits
150
+ """
151
+ def run_loop(self):
152
+ self.run_once()
153
+ self._terminate.set()
154
+
155
+ def run_cleanup(self):
156
+ pass
157
+
158
+ def run_init(self):
159
+ pass
160
+
161
+ @abstractmethod
162
+ def run_once(self):
163
+ """
164
+ Process behaviour should be implemented here
165
+ """
166
+ pass
@@ -0,0 +1,203 @@
1
+ from collections import OrderedDict
2
+ from typing import TypeVar, Generic, Iterator, Iterable, Optional, Set
3
+
4
+ T = TypeVar('T')
5
+
6
+ class BoundedOrderedSet(Generic[T]):
7
+ """
8
+ A set-like container with a maximum size that evicts oldest entries.
9
+
10
+ This class provides:
11
+ - Set semantics (unique elements, membership testing)
12
+ - Insertion order preservation
13
+ - Automatic LRU-style eviction when maxlen is reached
14
+ - O(1) membership testing, add, and remove operations
15
+
16
+ When maxlen is reached and a new item is added, the oldest item
17
+ (first inserted that hasn't been removed) is automatically evicted.
18
+
19
+ Thread-safety: Not thread-safe. Callers must provide external synchronization.
20
+
21
+ Example:
22
+ >>> bset = BoundedOrderedSet(maxlen=3)
23
+ >>> bset.add('a')
24
+ >>> bset.add('b')
25
+ >>> bset.add('c')
26
+ >>> bset.add('d') # 'a' is evicted
27
+ >>> list(bset)
28
+ ['b', 'c', 'd']
29
+ """
30
+
31
+ # Default maximum size (10,000 files is reasonable for most use cases)
32
+ DEFAULT_MAXLEN = 10000
33
+
34
+ def __init__(self, maxlen: Optional[int] = None, iterable: Optional[Iterable[T]] = None):
35
+ """
36
+ Initialize a bounded ordered set.
37
+
38
+ :param maxlen: Maximum number of elements. If None, uses DEFAULT_MAXLEN.
39
+ Must be positive.
40
+ :param iterable: Optional iterable of initial elements.
41
+ """
42
+ self._maxlen = maxlen if maxlen is not None else self.DEFAULT_MAXLEN
43
+ if self._maxlen < 1:
44
+ raise ValueError("maxlen must be positive, got {}".format(self._maxlen))
45
+
46
+ # Use OrderedDict as backing store - keys are elements, values are ignored
47
+ self._data: OrderedDict[T, None] = OrderedDict()
48
+
49
+ # Track total evictions for monitoring/debugging
50
+ self._total_evictions = 0
51
+
52
+ if iterable is not None:
53
+ for item in iterable:
54
+ self.add(item)
55
+
56
+ @property
57
+ def maxlen(self) -> int:
58
+ """Maximum number of elements allowed."""
59
+ return self._maxlen
60
+
61
+ @property
62
+ def total_evictions(self) -> int:
63
+ """Total number of elements evicted since creation."""
64
+ return self._total_evictions
65
+
66
+ def add(self, item: T) -> Optional[T]:
67
+ """
68
+ Add an item to the set.
69
+
70
+ If the item already exists, this is a no-op (it does NOT update order).
71
+ If adding would exceed maxlen, the oldest item is evicted first.
72
+
73
+ :param item: Item to add
74
+ :return: The evicted item if eviction occurred, None otherwise
75
+ """
76
+ # If item exists, do nothing (standard set behavior)
77
+ if item in self._data:
78
+ return None
79
+
80
+ evicted = None
81
+
82
+ # Evict oldest if at capacity
83
+ if len(self._data) >= self._maxlen:
84
+ # popitem(last=False) removes the first (oldest) item
85
+ evicted, _ = self._data.popitem(last=False)
86
+ self._total_evictions += 1
87
+
88
+ self._data[item] = None
89
+ return evicted
90
+
91
+ def touch(self, item: T) -> bool:
92
+ """
93
+ Move an item to the end (most recent position) if it exists.
94
+
95
+ This refreshes the item's position in the LRU order, preventing
96
+ it from being evicted soon. If the item doesn't exist, does nothing.
97
+
98
+ :param item: Item to refresh
99
+ :return: True if item was found and touched, False otherwise
100
+ """
101
+ if item not in self._data:
102
+ return False
103
+ # Move to end (most recent position)
104
+ self._data.move_to_end(item)
105
+ return True
106
+
107
+ def discard(self, item: T) -> None:
108
+ """
109
+ Remove an item from the set if present.
110
+
111
+ Does not raise an error if the item is not present.
112
+
113
+ :param item: Item to remove
114
+ """
115
+ self._data.pop(item, None)
116
+
117
+ def remove(self, item: T) -> None:
118
+ """
119
+ Remove an item from the set.
120
+
121
+ :param item: Item to remove
122
+ :raises KeyError: If item is not in the set
123
+ """
124
+ del self._data[item]
125
+
126
+ def clear(self) -> None:
127
+ """Remove all items from the set."""
128
+ self._data.clear()
129
+
130
+ def difference_update(self, other: Iterable[T]) -> None:
131
+ """
132
+ Remove all items that are in 'other' from this set.
133
+
134
+ Equivalent to: self -= other
135
+
136
+ :param other: Iterable of items to remove
137
+ """
138
+ for item in other:
139
+ self.discard(item)
140
+
141
+ def __contains__(self, item: T) -> bool:
142
+ """Check if item is in the set."""
143
+ return item in self._data
144
+
145
+ def __len__(self) -> int:
146
+ """Return the number of items in the set."""
147
+ return len(self._data)
148
+
149
+ def __iter__(self) -> Iterator[T]:
150
+ """Iterate over items in insertion order."""
151
+ return iter(self._data)
152
+
153
+ def __bool__(self) -> bool:
154
+ """Return True if the set is non-empty."""
155
+ return bool(self._data)
156
+
157
+ def __repr__(self) -> str:
158
+ items = list(self._data.keys())
159
+ return "BoundedOrderedSet({}, maxlen={})".format(items, self._maxlen)
160
+
161
+ def __eq__(self, other) -> bool:
162
+ """
163
+ Test equality with another BoundedOrderedSet or regular set.
164
+
165
+ Only compares contents, not maxlen or eviction count.
166
+ """
167
+ if isinstance(other, BoundedOrderedSet):
168
+ return set(self._data.keys()) == set(other._data.keys())
169
+ elif isinstance(other, (set, frozenset)):
170
+ return set(self._data.keys()) == other
171
+ return NotImplemented
172
+
173
+ def copy(self) -> "BoundedOrderedSet[T]":
174
+ """
175
+ Create a shallow copy of this set.
176
+
177
+ The copy has the same maxlen but eviction count is reset to 0.
178
+ """
179
+ new_set: BoundedOrderedSet[T] = BoundedOrderedSet(maxlen=self._maxlen)
180
+ new_set._data = self._data.copy()
181
+ return new_set
182
+
183
+ def as_set(self) -> Set[T]:
184
+ """Return a regular set containing all items."""
185
+ return set(self._data.keys())
186
+
187
+ def as_list(self) -> list:
188
+ """Return a list of items in insertion order."""
189
+ return list(self._data.keys())
190
+
191
+ @classmethod
192
+ def from_iterable(cls, iterable: Iterable[T], maxlen: Optional[int] = None) -> "BoundedOrderedSet[T]":
193
+ """
194
+ Create a BoundedOrderedSet from an iterable.
195
+
196
+ If the iterable has more items than maxlen, only the last maxlen
197
+ items (in iteration order) will be retained.
198
+
199
+ :param iterable: Source iterable
200
+ :param maxlen: Maximum size (uses DEFAULT_MAXLEN if None)
201
+ :return: New BoundedOrderedSet
202
+ """
203
+ return cls(maxlen=maxlen, iterable=iterable)