shareddata 6.83.17__tar.gz → 6.83.22__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 (110) hide show
  1. shareddata-6.83.22/MANIFEST.in +3 -0
  2. {shareddata-6.83.17/src/shareddata.egg-info → shareddata-6.83.22}/PKG-INFO +2 -3
  3. {shareddata-6.83.17 → shareddata-6.83.22}/setup.py +2 -2
  4. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/__init__.py +3 -0
  5. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/constants.py +7 -0
  6. shareddata-6.83.22/src/SharedData/API/download_paths.py +126 -0
  7. shareddata-6.83.22/src/SharedData/API/download_slots.py +142 -0
  8. shareddata-6.83.22/src/SharedData/API/routes/downloads.py +147 -0
  9. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Defaults.py +30 -5
  10. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientAPI.py +63 -0
  11. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/WorkerLib.py +1 -1
  12. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/SharedNumpy.py +128 -51
  13. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Table.py +1 -0
  14. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TimeSeriesDisk.py +1 -0
  15. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TimeseriesContainer.py +1 -0
  16. {shareddata-6.83.17 → shareddata-6.83.22/src/shareddata.egg-info}/PKG-INFO +2 -3
  17. {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/SOURCES.txt +4 -28
  18. shareddata-6.83.17/MANIFEST.in +0 -1
  19. shareddata-6.83.17/tests/test_api_collection.py +0 -119
  20. shareddata-6.83.17/tests/test_api_collection_loopback.py +0 -164
  21. shareddata-6.83.17/tests/test_api_table.py +0 -201
  22. shareddata-6.83.17/tests/test_api_table_production_flows.py +0 -414
  23. shareddata-6.83.17/tests/test_api_table_schemaless.py +0 -261
  24. shareddata-6.83.17/tests/test_api_table_schemaless_extend.py +0 -131
  25. shareddata-6.83.17/tests/test_bson_last_pos_reuse.py +0 -468
  26. shareddata-6.83.17/tests/test_cache_redis.py +0 -162
  27. shareddata-6.83.17/tests/test_extend_rt.py +0 -69
  28. shareddata-6.83.17/tests/test_listcache.py +0 -94
  29. shareddata-6.83.17/tests/test_loc.py +0 -149
  30. shareddata-6.83.17/tests/test_logger_thread_safety.py +0 -47
  31. shareddata-6.83.17/tests/test_metadata.py +0 -0
  32. shareddata-6.83.17/tests/test_mount_unmount_detection.py +0 -93
  33. shareddata-6.83.17/tests/test_read_write_tail.py +0 -48
  34. shareddata-6.83.17/tests/test_schema_acquire_timeout.py +0 -47
  35. shareddata-6.83.17/tests/test_stream_loopback_async.py +0 -169
  36. shareddata-6.83.17/tests/test_table_auto_grow_all_pkeys.py +0 -98
  37. shareddata-6.83.17/tests/test_timeseries.py +0 -1137
  38. shareddata-6.83.17/tests/test_timeseries_api.py +0 -391
  39. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_d1.py +0 -80
  40. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m1.py +0 -46
  41. shareddata-6.83.17/tests/test_upsert_unordered_get_date_loc_m15.py +0 -46
  42. shareddata-6.83.17/tests/test_upsert_unordered_positions_m1.py +0 -47
  43. shareddata-6.83.17/tests/test_workerpool_jobs_status.py +0 -283
  44. shareddata-6.83.17/tests/test_workerpool_mutex_release.py +0 -55
  45. shareddata-6.83.17/tests/test_workerpool_post_commands.py +0 -146
  46. {shareddata-6.83.17 → shareddata-6.83.22}/LICENSE +0 -0
  47. {shareddata-6.83.17 → shareddata-6.83.22}/README.md +0 -0
  48. {shareddata-6.83.17 → shareddata-6.83.22}/pyproject.toml +0 -0
  49. {shareddata-6.83.17 → shareddata-6.83.22}/setup.cfg +0 -0
  50. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/ServerGunicorn.py +0 -0
  51. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/ServerWaitress.py +0 -0
  52. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/auth.py +0 -0
  53. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/listcache.py +0 -0
  54. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/__init__.py +0 -0
  55. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/cache.py +0 -0
  56. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/collections.py +0 -0
  57. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/metadata.py +0 -0
  58. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/system.py +0 -0
  59. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/tables.py +0 -0
  60. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/timeseries.py +0 -0
  61. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/routes/workers.py +0 -0
  62. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/API/utils.py +0 -0
  63. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/CacheRedis.py +0 -0
  64. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/CollectionMongoDB.py +0 -0
  65. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Database.py +0 -0
  66. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AWSEC2.py +0 -0
  67. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AWSS3.py +0 -0
  68. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/AutoDocstrings.py +0 -0
  69. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientSocket.py +0 -0
  70. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ClientWebSocket.py +0 -0
  71. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/LogHandlerAPI.py +0 -0
  72. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/MongoDBClient.py +0 -0
  73. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/SaveTables.py +0 -0
  74. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ServerSocket.py +0 -0
  75. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/ServerWebSocket.py +0 -0
  76. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/StreamsCache.py +0 -0
  77. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/StreamsPersist.py +0 -0
  78. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/SyncTable.py +0 -0
  79. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/TunnelWebSocket.py +0 -0
  80. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/IO/__init__.py +0 -0
  81. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Logger.py +0 -0
  82. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Metadata.py +0 -0
  83. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/MultiProc.py +0 -0
  84. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/OpenFIGI.py +0 -0
  85. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
  86. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/BatchJob.py +0 -0
  87. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Schedule.py +0 -0
  88. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
  89. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Scheduler.py +0 -0
  90. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
  91. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/Worker.py +0 -0
  92. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/WorkerPool.py +0 -0
  93. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Routines/__init__.py +0 -0
  94. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/SharedData.py +0 -0
  95. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/StreamKafka.py +0 -0
  96. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Symbol.py +0 -0
  97. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableDisk.py +0 -0
  98. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndex.py +0 -0
  99. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitFunctions.py +0 -0
  100. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
  101. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitGenerate.py +0 -0
  102. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitHash.py +0 -0
  103. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/TableIndexJitLoc.py +0 -0
  104. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Users.py +0 -0
  105. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/Utils.py +0 -0
  106. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/__init__.py +0 -0
  107. {shareddata-6.83.17 → shareddata-6.83.22}/src/SharedData/sharedmutexwin.pyd +0 -0
  108. {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/dependency_links.txt +0 -0
  109. {shareddata-6.83.17 → shareddata-6.83.22}/src/shareddata.egg-info/requires.txt +0 -0
  110. {shareddata-6.83.17 → shareddata-6.83.22}/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,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shareddata
3
- Version: 6.83.17
3
+ Version: 6.83.22
4
4
  Summary: Memory Mapped / Shared Memory Database with S3 repository
5
- License: MIT
6
5
  Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
7
  Classifier: Operating System :: OS Independent
8
8
  Requires-Python: >=3.9, <3.13
9
9
  Description-Content-Type: text/markdown
@@ -39,7 +39,6 @@ Requires-Dist: redis==6.2.0
39
39
  Dynamic: classifier
40
40
  Dynamic: description
41
41
  Dynamic: description-content-type
42
- Dynamic: license
43
42
  Dynamic: license-file
44
43
  Dynamic: requires-dist
45
44
  Dynamic: requires-python
@@ -34,13 +34,13 @@ install_requires = [
34
34
 
35
35
  setup(
36
36
  name='shareddata',
37
- version='6.83.17',
37
+ version='6.83.22',
38
38
  description='Memory Mapped / Shared Memory Database with S3 repository',
39
- license='MIT',
40
39
  long_description=open('README.md').read(),
41
40
  long_description_content_type='text/markdown',
42
41
  classifiers=[
43
42
  'Programming Language :: Python :: 3',
43
+ 'License :: OSI Approved :: MIT License',
44
44
  'Operating System :: OS Independent',
45
45
  ],
46
46
  packages=find_packages(where='src'),
@@ -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
@@ -221,6 +221,27 @@ def _classify_on_mounted_device(resolved_path, current_mounts, declared_mounts):
221
221
  return True
222
222
 
223
223
 
224
+ def _windows_folder_is_live(folder: str) -> bool:
225
+ """Windows has no fstab/mountinfo stub semantics; an existing dir is live."""
226
+ try:
227
+ return bool(folder) and Path(folder).is_dir()
228
+ except (OSError, TypeError):
229
+ return False
230
+
231
+
232
+ def _is_filesystem_root(current) -> bool:
233
+ """True for POSIX ``/``, Windows drive roots (``C:\\``), and UNC share roots.
234
+
235
+ ``Path('C:\\\\').parent`` is itself and ``os.path.ismount('C:\\\\')`` is
236
+ True, so the st_dev==parent_dev stub heuristic must not treat a drive
237
+ letter as an unmounted mountpoint.
238
+ """
239
+ try:
240
+ return current == current.parent or current == type(current)(current.anchor)
241
+ except (OSError, TypeError, ValueError):
242
+ return False
243
+
244
+
224
245
  def _is_on_mounted_device_legacy(folder: str) -> bool:
225
246
  """Original ``os.path.ismount()`` st_dev heuristic — fallback for platforms
226
247
  without ``/proc/self/mountinfo``. Cannot detect an unmounted mountpoint
@@ -232,20 +253,20 @@ def _is_on_mounted_device_legacy(folder: str) -> bool:
232
253
  while True:
233
254
  if os.path.ismount(current):
234
255
  try:
256
+ # Drive / UNC / POSIX root is a live filesystem, not a stub.
257
+ if _is_filesystem_root(current):
258
+ return True
235
259
  dev = current.stat().st_dev
236
260
  parent_dev = current.parent.stat().st_dev
237
261
  if dev != parent_dev:
238
262
  return True # device is mounted
239
263
  else:
240
264
  # Mountpoint stub — device not mounted.
241
- # But if this is /, it's the root fs (always live).
242
- if current == Path('/'):
243
- return True
244
265
  return False
245
266
  except OSError:
246
267
  return False
247
268
  if current == current.parent:
248
- # Reached / without finding a mountpoint — root fs.
269
+ # Reached filesystem root without finding a mountpoint — live.
249
270
  return True
250
271
  current = current.parent
251
272
  except (OSError, TypeError):
@@ -265,11 +286,15 @@ def _is_on_mounted_device(folder: str) -> bool:
265
286
  detection (writes silently landing on ``/`` instead of the intended disk).
266
287
 
267
288
  Falls back to the legacy ``os.path.ismount()`` heuristic when mountinfo is
268
- unavailable (non-Linux).
289
+ unavailable (non-Linux). On Windows there is no fstab/mountinfo stub
290
+ semantics — an existing directory is live (drive roots are not unmounted
291
+ mountpoint stubs).
269
292
  """
270
293
  try:
271
294
  if not folder:
272
295
  return False
296
+ if os.name == 'nt':
297
+ return _windows_folder_is_live(folder)
273
298
  resolved = str(Path(folder).resolve())
274
299
  current = _current_mount_points()
275
300
  if current is None:
@@ -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
@@ -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'