shareddata 6.83.16__tar.gz → 6.83.21__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 (108) hide show
  1. shareddata-6.83.21/MANIFEST.in +3 -0
  2. {shareddata-6.83.16/src/shareddata.egg-info → shareddata-6.83.21}/PKG-INFO +1 -1
  3. {shareddata-6.83.16 → shareddata-6.83.21}/setup.py +1 -1
  4. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/__init__.py +3 -0
  5. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/constants.py +7 -0
  6. shareddata-6.83.21/src/SharedData/API/download_paths.py +126 -0
  7. shareddata-6.83.21/src/SharedData/API/download_slots.py +142 -0
  8. shareddata-6.83.21/src/SharedData/API/routes/downloads.py +147 -0
  9. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientAPI.py +63 -0
  10. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/MongoDBClient.py +1 -1
  11. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Worker.py +2 -2
  12. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/WorkerLib.py +1 -1
  13. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/SharedNumpy.py +128 -51
  14. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Table.py +1 -0
  15. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TimeSeriesDisk.py +1 -0
  16. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TimeseriesContainer.py +1 -0
  17. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/sharedmutexwin.pyd +0 -0
  18. {shareddata-6.83.16 → shareddata-6.83.21/src/shareddata.egg-info}/PKG-INFO +1 -1
  19. {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/SOURCES.txt +4 -26
  20. shareddata-6.83.16/MANIFEST.in +0 -2
  21. shareddata-6.83.16/tests/test_api_collection.py +0 -119
  22. shareddata-6.83.16/tests/test_api_collection_loopback.py +0 -164
  23. shareddata-6.83.16/tests/test_api_table.py +0 -201
  24. shareddata-6.83.16/tests/test_api_table_production_flows.py +0 -414
  25. shareddata-6.83.16/tests/test_api_table_schemaless.py +0 -261
  26. shareddata-6.83.16/tests/test_api_table_schemaless_extend.py +0 -131
  27. shareddata-6.83.16/tests/test_bson_last_pos_reuse.py +0 -468
  28. shareddata-6.83.16/tests/test_cache_redis.py +0 -162
  29. shareddata-6.83.16/tests/test_extend_rt.py +0 -69
  30. shareddata-6.83.16/tests/test_listcache.py +0 -94
  31. shareddata-6.83.16/tests/test_loc.py +0 -149
  32. shareddata-6.83.16/tests/test_metadata.py +0 -0
  33. shareddata-6.83.16/tests/test_mount_unmount_detection.py +0 -93
  34. shareddata-6.83.16/tests/test_read_write_tail.py +0 -48
  35. shareddata-6.83.16/tests/test_schema_acquire_timeout.py +0 -47
  36. shareddata-6.83.16/tests/test_stream_loopback_async.py +0 -169
  37. shareddata-6.83.16/tests/test_table_auto_grow_all_pkeys.py +0 -98
  38. shareddata-6.83.16/tests/test_timeseries.py +0 -1137
  39. shareddata-6.83.16/tests/test_timeseries_api.py +0 -391
  40. shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_d1.py +0 -80
  41. shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_m1.py +0 -46
  42. shareddata-6.83.16/tests/test_upsert_unordered_get_date_loc_m15.py +0 -46
  43. shareddata-6.83.16/tests/test_upsert_unordered_positions_m1.py +0 -47
  44. shareddata-6.83.16/tests/test_workerpool_mutex_release.py +0 -55
  45. shareddata-6.83.16/tests/test_workerpool_post_commands.py +0 -141
  46. {shareddata-6.83.16 → shareddata-6.83.21}/LICENSE +0 -0
  47. {shareddata-6.83.16 → shareddata-6.83.21}/README.md +0 -0
  48. {shareddata-6.83.16 → shareddata-6.83.21}/pyproject.toml +0 -0
  49. {shareddata-6.83.16 → shareddata-6.83.21}/setup.cfg +0 -0
  50. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/ServerGunicorn.py +0 -0
  51. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/ServerWaitress.py +0 -0
  52. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/auth.py +0 -0
  53. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/listcache.py +0 -0
  54. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/__init__.py +0 -0
  55. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/cache.py +0 -0
  56. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/collections.py +0 -0
  57. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/metadata.py +0 -0
  58. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/system.py +0 -0
  59. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/tables.py +0 -0
  60. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/timeseries.py +0 -0
  61. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/routes/workers.py +0 -0
  62. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/API/utils.py +0 -0
  63. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/CacheRedis.py +0 -0
  64. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/CollectionMongoDB.py +0 -0
  65. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Database.py +0 -0
  66. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Defaults.py +0 -0
  67. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AWSEC2.py +0 -0
  68. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AWSS3.py +0 -0
  69. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/AutoDocstrings.py +0 -0
  70. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientSocket.py +0 -0
  71. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ClientWebSocket.py +0 -0
  72. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  73. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/SaveTables.py +0 -0
  74. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ServerSocket.py +0 -0
  75. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/ServerWebSocket.py +0 -0
  76. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/StreamsCache.py +0 -0
  77. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/StreamsPersist.py +0 -0
  78. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/SyncTable.py +0 -0
  79. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  80. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/IO/__init__.py +0 -0
  81. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Logger.py +0 -0
  82. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Metadata.py +0 -0
  83. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/MultiProc.py +0 -0
  84. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/OpenFIGI.py +0 -0
  85. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
  86. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/BatchJob.py +0 -0
  87. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Schedule.py +0 -0
  88. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  89. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/Scheduler.py +0 -0
  90. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
  91. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/WorkerPool.py +0 -0
  92. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Routines/__init__.py +0 -0
  93. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/SharedData.py +0 -0
  94. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/StreamKafka.py +0 -0
  95. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Symbol.py +0 -0
  96. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableDisk.py +0 -0
  97. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndex.py +0 -0
  98. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctions.py +0 -0
  99. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  100. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitGenerate.py +0 -0
  101. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitHash.py +0 -0
  102. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/TableIndexJitLoc.py +0 -0
  103. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Users.py +0 -0
  104. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/Utils.py +0 -0
  105. {shareddata-6.83.16 → shareddata-6.83.21}/src/SharedData/__init__.py +0 -0
  106. {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/dependency_links.txt +0 -0
  107. {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/requires.txt +0 -0
  108. {shareddata-6.83.16 → shareddata-6.83.21}/src/shareddata.egg-info/top_level.txt +0 -0
@@ -0,0 +1,3 @@
1
+ include src/SharedData/sharedmutexwin.pyd
2
+ include src/SharedData/IO/apidocs.yml
3
+ prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.83.16
3
+ Version: 6.83.21
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -34,7 +34,7 @@ install_requires = [
34
34
 
35
35
  setup(
36
36
  name='shareddata',
37
- version='6.83.16',
37
+ version='6.83.21',
38
38
  description='Memory Mapped / Shared Memory Database with S3 repository',
39
39
  long_description=open('README.md').read(),
40
40
  long_description_content_type='text/markdown',
@@ -21,6 +21,7 @@ from SharedData.API.routes.timeseries import timeseries_bp, init_timeseries_rout
21
21
  from SharedData.API.routes.workers import workers_bp, init_workers_routes
22
22
  from SharedData.API.routes.metadata import metadata_bp, init_metadata_routes
23
23
  from SharedData.API.routes.cache import cache_bp, init_cache_routes
24
+ from SharedData.API.routes.downloads import init_downloads_routes
24
25
  from SharedData.API.routes.system import (
25
26
  system_bp,
26
27
  init_system_routes,
@@ -85,6 +86,7 @@ def create_app(shdata=None, quiet=True):
85
86
  init_workers_routes(shdata, authenticate_fn)
86
87
  init_metadata_routes(shdata, authenticate_fn)
87
88
  init_cache_routes(shdata, authenticate_fn)
89
+ downloads_bp = init_downloads_routes(shdata, authenticate_fn)
88
90
  init_system_routes(shdata, authenticate_fn)
89
91
 
90
92
  # Register blueprints with /api prefix
@@ -94,6 +96,7 @@ def create_app(shdata=None, quiet=True):
94
96
  app.register_blueprint(workers_bp, url_prefix='/api')
95
97
  app.register_blueprint(metadata_bp, url_prefix='/api')
96
98
  app.register_blueprint(cache_bp, url_prefix='/api')
99
+ app.register_blueprint(downloads_bp, url_prefix='/api')
97
100
  app.register_blueprint(system_bp, url_prefix='/api')
98
101
 
99
102
  # Register request lifecycle hooks
@@ -29,3 +29,10 @@ FORMAT_JSON = 'json'
29
29
  FORMAT_CSV = 'csv'
30
30
  FORMAT_BIN = 'bin'
31
31
  FORMAT_BSON = 'bson'
32
+
33
+ # Downloads explorer (/api/list, /api/download)
34
+ DOWNLOAD_SLOT_LIMIT = 5
35
+ DOWNLOAD_SLOT_TTL_SECONDS = 6 * 60 * 60 # leak-safety if a worker dies mid-transfer
36
+ LIST_DEFAULT_DEPTH = 1
37
+ LIST_MAX_DEPTH = 3
38
+ LIST_MAX_ENTRIES = 5000
@@ -0,0 +1,126 @@
1
+ """
2
+ Path safety and directory listing for the DOWNLOADS_FOLDER explorer.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from SharedData.API.constants import LIST_MAX_ENTRIES
11
+
12
+
13
+ class PathEscapeError(ValueError):
14
+ """Requested path is outside DOWNLOADS_FOLDER."""
15
+
16
+
17
+ class DownloadsRootError(RuntimeError):
18
+ """DOWNLOADS_FOLDER is missing or not a directory."""
19
+
20
+
21
+ def downloads_root() -> Path:
22
+ raw = os.environ.get('DOWNLOADS_FOLDER')
23
+ if not raw:
24
+ raise DownloadsRootError('DOWNLOADS_FOLDER is not set')
25
+ root = Path(raw).resolve()
26
+ if not root.is_dir():
27
+ raise DownloadsRootError(f'DOWNLOADS_FOLDER is not a directory: {root}')
28
+ return root
29
+
30
+
31
+ def resolve_under_downloads(relpath: str | None) -> Path:
32
+ """
33
+ Resolve a client-supplied relative path so it stays inside DOWNLOADS_FOLDER.
34
+
35
+ Leading slashes are stripped. ``.``, empty, and ``/`` mean the folder root.
36
+ """
37
+ root = downloads_root()
38
+ rel = (relpath or '').strip()
39
+ if rel in ('', '.', '/'):
40
+ return root
41
+ if '\x00' in rel:
42
+ raise PathEscapeError('invalid path')
43
+ rel = rel.lstrip('/')
44
+ candidate = (root / rel).resolve()
45
+ try:
46
+ candidate.relative_to(root)
47
+ except ValueError as exc:
48
+ raise PathEscapeError('path escapes DOWNLOADS_FOLDER') from exc
49
+ return candidate
50
+
51
+
52
+ def _relative_posix(path: Path, root: Path) -> str:
53
+ if path == root:
54
+ return ''
55
+ return path.relative_to(root).as_posix()
56
+
57
+
58
+ def _skip_name(name: str) -> bool:
59
+ return name.startswith('.')
60
+
61
+
62
+ def list_entries(
63
+ relpath: str | None,
64
+ depth: int,
65
+ max_entries: int = LIST_MAX_ENTRIES,
66
+ ) -> dict[str, Any]:
67
+ """
68
+ List files and directories under ``relpath`` up to ``depth`` levels.
69
+
70
+ ``depth=1`` is the requested directory's immediate children only.
71
+ Hidden names (leading ``.``) are omitted. Escaping symlinks are skipped.
72
+ """
73
+ root = downloads_root()
74
+ base = resolve_under_downloads(relpath)
75
+ if not base.exists():
76
+ raise FileNotFoundError(f'path not found: {relpath}')
77
+ if not base.is_dir():
78
+ raise NotADirectoryError(f'not a directory: {relpath}')
79
+
80
+ entries: list[dict[str, Any]] = []
81
+ truncated = False
82
+
83
+ def walk(dirpath: Path, remaining: int) -> None:
84
+ nonlocal truncated
85
+ if truncated:
86
+ return
87
+ try:
88
+ children = sorted(dirpath.iterdir(), key=lambda p: p.name.lower())
89
+ except OSError:
90
+ return
91
+ for child in children:
92
+ if truncated:
93
+ return
94
+ if _skip_name(child.name):
95
+ continue
96
+ try:
97
+ resolved = child.resolve()
98
+ resolved.relative_to(root)
99
+ except (OSError, ValueError):
100
+ continue
101
+ try:
102
+ st = child.stat()
103
+ except OSError:
104
+ continue
105
+ is_dir = child.is_dir()
106
+ entries.append({
107
+ 'name': child.name,
108
+ 'path': _relative_posix(resolved if not is_dir else child.resolve(), root),
109
+ 'type': 'dir' if is_dir else 'file',
110
+ 'size': None if is_dir else int(st.st_size),
111
+ 'mtime': int(st.st_mtime),
112
+ })
113
+ if len(entries) >= max_entries:
114
+ truncated = True
115
+ return
116
+ if is_dir and remaining > 1:
117
+ walk(child, remaining - 1)
118
+
119
+ walk(base, depth)
120
+ return {
121
+ 'path': _relative_posix(base, root),
122
+ 'depth': depth,
123
+ 'truncated': truncated,
124
+ 'count': len(entries),
125
+ 'entries': entries,
126
+ }
@@ -0,0 +1,142 @@
1
+ """
2
+ Global concurrent-download limiter.
3
+
4
+ Gunicorn runs multiple worker processes (production: 8), so an in-memory
5
+ counter would allow limit × workers. This limiter is process- and host-safe:
6
+
7
+ - Redis (default when REDIS_CLUSTER_NODES is set): one counter for the
8
+ whole SharedData deployment.
9
+ - File lock under DOWNLOADS_FOLDER: fallback for tests / Redis-unavailable,
10
+ still global across hosts that share that NFS folder.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from filelock import FileLock
18
+
19
+ from SharedData.API.constants import DOWNLOAD_SLOT_LIMIT, DOWNLOAD_SLOT_TTL_SECONDS
20
+ from SharedData.API.download_paths import downloads_root
21
+ from SharedData.Logger import Logger
22
+
23
+ _REDIS_SLOT_KEY = '{shareddata}:download_slots'
24
+
25
+ _ACQUIRE_LUA = r"""
26
+ local n = tonumber(redis.call('GET', KEYS[1]) or '0')
27
+ if n >= tonumber(ARGV[1]) then
28
+ return -1
29
+ end
30
+ n = redis.call('INCR', KEYS[1])
31
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
32
+ return n
33
+ """
34
+
35
+ _RELEASE_LUA = r"""
36
+ local n = tonumber(redis.call('GET', KEYS[1]) or '0')
37
+ if n <= 0 then
38
+ redis.call('SET', KEYS[1], 0)
39
+ return 0
40
+ end
41
+ return redis.call('DECR', KEYS[1])
42
+ """
43
+
44
+ _limiter = None
45
+
46
+
47
+ class DownloadSlotLimiter:
48
+ def acquire(self) -> bool:
49
+ raise NotImplementedError
50
+
51
+ def release(self) -> None:
52
+ raise NotImplementedError
53
+
54
+ def active(self) -> int:
55
+ raise NotImplementedError
56
+
57
+
58
+ class FileDownloadSlotLimiter(DownloadSlotLimiter):
59
+ def __init__(self, root: Path | None = None, limit: int = DOWNLOAD_SLOT_LIMIT):
60
+ self.limit = limit
61
+ self.root = root if root is not None else downloads_root()
62
+ self.count_path = self.root / '.shareddata_download_slots.count'
63
+ self.lock_path = str(self.root / '.shareddata_download_slots.lock')
64
+
65
+ def _read(self) -> int:
66
+ try:
67
+ return max(0, int(self.count_path.read_text().strip() or '0'))
68
+ except (OSError, ValueError):
69
+ return 0
70
+
71
+ def acquire(self) -> bool:
72
+ with FileLock(self.lock_path):
73
+ n = self._read()
74
+ if n >= self.limit:
75
+ return False
76
+ self.count_path.write_text(str(n + 1))
77
+ return True
78
+
79
+ def release(self) -> None:
80
+ with FileLock(self.lock_path):
81
+ n = self._read()
82
+ self.count_path.write_text(str(max(0, n - 1)))
83
+
84
+ def active(self) -> int:
85
+ with FileLock(self.lock_path):
86
+ return self._read()
87
+
88
+
89
+ class RedisDownloadSlotLimiter(DownloadSlotLimiter):
90
+ def __init__(self, limit: int = DOWNLOAD_SLOT_LIMIT, ttl: int = DOWNLOAD_SLOT_TTL_SECONDS):
91
+ from SharedData.CacheRedis import _build_sync_client
92
+
93
+ self.limit = limit
94
+ self.ttl = ttl
95
+ self._client = _build_sync_client()
96
+ self._acquire = self._client.register_script(_ACQUIRE_LUA)
97
+ self._release = self._client.register_script(_RELEASE_LUA)
98
+
99
+ def acquire(self) -> bool:
100
+ result = self._acquire(keys=[_REDIS_SLOT_KEY], args=[self.limit, self.ttl])
101
+ return int(result) > 0
102
+
103
+ def release(self) -> None:
104
+ self._release(keys=[_REDIS_SLOT_KEY], args=[])
105
+
106
+ def active(self) -> int:
107
+ raw = self._client.get(_REDIS_SLOT_KEY)
108
+ if raw is None:
109
+ return 0
110
+ try:
111
+ return max(0, int(raw))
112
+ except (TypeError, ValueError):
113
+ return 0
114
+
115
+
116
+ def _make_limiter() -> DownloadSlotLimiter:
117
+ backend = os.environ.get('SHAREDDATA_DOWNLOAD_SLOT_BACKEND', '').strip().lower()
118
+ if backend == 'file':
119
+ return FileDownloadSlotLimiter()
120
+ if backend == 'redis' or os.environ.get('REDIS_CLUSTER_NODES'):
121
+ try:
122
+ return RedisDownloadSlotLimiter()
123
+ except Exception as exc:
124
+ Logger.log.warning(
125
+ 'download slot limiter: Redis unavailable (%s); using file backend',
126
+ exc,
127
+ )
128
+ return FileDownloadSlotLimiter()
129
+ return FileDownloadSlotLimiter()
130
+
131
+
132
+ def get_limiter() -> DownloadSlotLimiter:
133
+ global _limiter
134
+ if _limiter is None:
135
+ _limiter = _make_limiter()
136
+ return _limiter
137
+
138
+
139
+ def reset_limiter() -> None:
140
+ """Drop the cached limiter (tests only)."""
141
+ global _limiter
142
+ _limiter = None
@@ -0,0 +1,147 @@
1
+ """
2
+ Downloads explorer blueprint: GET /api/list and GET /api/download.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import time
7
+
8
+ from flask import Blueprint, Response, jsonify, request, stream_with_context
9
+
10
+ from SharedData.API.constants import (
11
+ DOWNLOAD_SLOT_LIMIT,
12
+ ERROR_SLEEP_SECONDS,
13
+ LIST_DEFAULT_DEPTH,
14
+ LIST_MAX_DEPTH,
15
+ )
16
+ from SharedData.API.download_paths import (
17
+ DownloadsRootError,
18
+ PathEscapeError,
19
+ list_entries,
20
+ resolve_under_downloads,
21
+ )
22
+ from SharedData.API.download_slots import get_limiter
23
+ from SharedData.API.utils import make_error_response, make_json_response, parse_int_param
24
+ from SharedData.Logger import Logger
25
+
26
+
27
+ def _clamp_depth(raw: str | None) -> int:
28
+ depth = parse_int_param(raw, LIST_DEFAULT_DEPTH)
29
+ if depth < 1:
30
+ return 1
31
+ if depth > LIST_MAX_DEPTH:
32
+ return LIST_MAX_DEPTH
33
+ return depth
34
+
35
+
36
+ def init_downloads_routes(shdata, authenticate_fn):
37
+ """
38
+ Initialize downloads routes with required dependencies.
39
+
40
+ Returns a fresh blueprint so create_app / tests can register it more
41
+ than once in the same process.
42
+
43
+ Parameters:
44
+ shdata: SharedData instance.
45
+ authenticate_fn: Authentication function.
46
+ """
47
+ downloads_bp = Blueprint('downloads', __name__)
48
+
49
+ @downloads_bp.route('/list', methods=['GET'])
50
+ def list_downloads():
51
+ """GET /api/list — list files under DOWNLOADS_FOLDER up to a depth cap."""
52
+ try:
53
+ if not authenticate_fn(request, shdata):
54
+ return jsonify({'error': 'unauthorized'}), 401
55
+
56
+ depth = _clamp_depth(request.args.get('depth'))
57
+ payload = list_entries(request.args.get('path'), depth)
58
+ return make_json_response(payload)
59
+ except PathEscapeError as e:
60
+ return make_json_response({'error': str(e)}, status=400)
61
+ except DownloadsRootError as e:
62
+ return make_json_response({'error': str(e)}, status=503)
63
+ except FileNotFoundError as e:
64
+ return make_json_response({'error': str(e)}, status=404)
65
+ except NotADirectoryError as e:
66
+ return make_json_response({'error': str(e)}, status=400)
67
+ except Exception as e:
68
+ Logger.log.error(f'list_downloads error: {e}')
69
+ time.sleep(ERROR_SLEEP_SECONDS)
70
+ return make_error_response(e)
71
+
72
+ @downloads_bp.route('/download', methods=['GET'])
73
+ def download_file():
74
+ """GET /api/download — stream one regular file; 429 if 5 transfers are active."""
75
+ try:
76
+ if not authenticate_fn(request, shdata):
77
+ return jsonify({'error': 'unauthorized'}), 401
78
+
79
+ target = resolve_under_downloads(request.args.get('path'))
80
+ if not target.exists():
81
+ return make_json_response(
82
+ {'error': f'path not found: {request.args.get("path")}'},
83
+ status=404,
84
+ )
85
+ if not target.is_file():
86
+ return make_json_response(
87
+ {'error': 'not a file'},
88
+ status=400,
89
+ )
90
+
91
+ limiter = get_limiter()
92
+ if not limiter.acquire():
93
+ return make_json_response(
94
+ {
95
+ 'error': 'download limit reached',
96
+ 'limit': DOWNLOAD_SLOT_LIMIT,
97
+ 'active': limiter.active(),
98
+ },
99
+ status=429,
100
+ )
101
+
102
+ released = False
103
+
104
+ def _release_once():
105
+ nonlocal released
106
+ if released:
107
+ return
108
+ released = True
109
+ limiter.release()
110
+
111
+ def _stream():
112
+ try:
113
+ with target.open('rb') as fh:
114
+ while True:
115
+ chunk = fh.read(1024 * 1024)
116
+ if not chunk:
117
+ break
118
+ yield chunk
119
+ finally:
120
+ _release_once()
121
+
122
+ try:
123
+ response = Response(
124
+ stream_with_context(_stream()),
125
+ mimetype='application/octet-stream',
126
+ direct_passthrough=True,
127
+ )
128
+ response.headers['Content-Disposition'] = (
129
+ f'attachment; filename="{target.name}"'
130
+ )
131
+ response.headers['Content-Length'] = str(target.stat().st_size)
132
+ response.call_on_close(_release_once)
133
+ except Exception:
134
+ _release_once()
135
+ raise
136
+
137
+ return response
138
+ except PathEscapeError as e:
139
+ return make_json_response({'error': str(e)}, status=400)
140
+ except DownloadsRootError as e:
141
+ return make_json_response({'error': str(e)}, status=503)
142
+ except Exception as e:
143
+ Logger.log.error(f'download_file error: {e}')
144
+ time.sleep(ERROR_SLEEP_SECONDS)
145
+ return make_error_response(e)
146
+
147
+ return downloads_bp
@@ -3054,3 +3054,66 @@ class ClientAPI:
3054
3054
  response = requests.post(url, params=params)
3055
3055
  ClientAPI.raise_for_status(response)
3056
3056
  return response.json().get('deleted', 0)
3057
+
3058
+ ################# DOWNLOADS #################
3059
+
3060
+ @staticmethod
3061
+ def list_downloads(path: str = '', depth: int = 1,
3062
+ endpoint=None, token=None):
3063
+ """
3064
+ GET /api/list — list files and folders under DOWNLOADS_FOLDER.
3065
+
3066
+ Parameters:
3067
+ path (str): Relative path under DOWNLOADS_FOLDER. Empty lists the root.
3068
+ depth (int): Directory depth to include (server caps at 3).
3069
+ endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
3070
+ token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
3071
+
3072
+ Returns:
3073
+ dict: {path, depth, truncated, count, entries}
3074
+ """
3075
+ url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
3076
+ url += '/api/list'
3077
+ params = {'path': path, 'depth': depth}
3078
+ if token is not None:
3079
+ params['token'] = token
3080
+ else:
3081
+ params['token'] = os.environ.get('SHAREDDATA_TOKEN')
3082
+ if not params['token']:
3083
+ raise Exception('SHAREDDATA_TOKEN not found in environment variables')
3084
+ response = requests.get(url, params=params, headers={'Accept': 'application/json'})
3085
+ ClientAPI.raise_for_status(response)
3086
+ return response.json()
3087
+
3088
+ @staticmethod
3089
+ def download_file(path: str, dest, endpoint=None, token=None):
3090
+ """
3091
+ GET /api/download — stream one file from DOWNLOADS_FOLDER to ``dest``.
3092
+
3093
+ Parameters:
3094
+ path (str): Relative file path under DOWNLOADS_FOLDER.
3095
+ dest: Local filesystem path to write.
3096
+ endpoint (str, optional): API base URL. Defaults to SHAREDDATA_ENDPOINT.
3097
+ token (str, optional): Auth token. Defaults to SHAREDDATA_TOKEN.
3098
+
3099
+ Returns:
3100
+ str: Absolute destination path.
3101
+ """
3102
+ url = os.environ['SHAREDDATA_ENDPOINT'] if not endpoint else endpoint
3103
+ url += '/api/download'
3104
+ params = {'path': path}
3105
+ if token is not None:
3106
+ params['token'] = token
3107
+ else:
3108
+ params['token'] = os.environ.get('SHAREDDATA_TOKEN')
3109
+ if not params['token']:
3110
+ raise Exception('SHAREDDATA_TOKEN not found in environment variables')
3111
+ response = requests.get(url, params=params, stream=True)
3112
+ ClientAPI.raise_for_status(response)
3113
+ dest_path = os.path.abspath(str(dest))
3114
+ os.makedirs(os.path.dirname(dest_path) or '.', exist_ok=True)
3115
+ with open(dest_path, 'wb') as fh:
3116
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
3117
+ if chunk:
3118
+ fh.write(chunk)
3119
+ return dest_path
@@ -62,7 +62,7 @@ class MongoDBClient:
62
62
  f'&serverSelectionTimeoutMS=10000'
63
63
  )
64
64
  elif 'MONGODB_REPLICA_SET' not in os.environ:
65
- # Single server connection (MONGODB_HOST includes port: '10.0.0.50:27017')
65
+ # Single server connection (MONGODB_HOST includes port: '192.0.2.10:27017')
66
66
  self.mongodb_conn_str = (
67
67
  f'mongodb://{os.environ["MONGODB_USER"]}:'
68
68
  f'{os.environ["MONGODB_PWD"]}@'
@@ -226,8 +226,8 @@ while True:
226
226
 
227
227
  # Scheduler lifecycle — watchdog works in both election and non-election
228
228
  # modes. In election mode, only the current master owns the scheduler and
229
- # runs 'MASTER'. In non-election mode (static deploy, e.g. TB08-WS with
230
- # --schedules MASTER,TRADEBOT08-VM1), this worker always owns the scheduler
229
+ # runs 'MASTER'. In non-election mode (static deploy, e.g. WORKER01 with
230
+ # --schedules MASTER,WORKER02), this worker always owns the scheduler
231
231
  # and runs its configured SCHEDULE_NAMES.
232
232
  election_mode = str(os.environ.get('RUN_MASTER_ELECTION', '')).upper() == 'TRUE'
233
233
  if election_mode:
@@ -42,7 +42,7 @@ def get_own_ip():
42
42
  s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
43
43
  try:
44
44
  # Doesn't need to be reachable, just used to determine the interface
45
- s.connect(('10.255.255.255', 1))
45
+ s.connect(('192.0.2.1', 1))
46
46
  own_ip = s.getsockname()[0]
47
47
  except Exception:
48
48
  own_ip = '127.0.0.1'