lfss 0.7.15__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lfss/api/connector.py ADDED
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, Literal, Iterator
3
+ import os
4
+ import requests
5
+ import requests.adapters
6
+ import urllib.parse
7
+ from tempfile import SpooledTemporaryFile
8
+ from lfss.src.error import PathNotFoundError
9
+ from lfss.src.datatype import (
10
+ FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
11
+ FileSortKey, DirSortKey
12
+ )
13
+ from lfss.src.utils import ensure_uri_compnents
14
+
15
+ _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
16
+ _default_token = os.environ.get('LFSS_TOKEN', '')
17
+
18
+ class Connector:
19
+ class Session:
20
+ def __init__(self, connector: Connector, pool_size: int = 10):
21
+ self.connector = connector
22
+ self.pool_size = pool_size
23
+ def open(self):
24
+ self.close()
25
+ if self.connector._session is None:
26
+ s = requests.Session()
27
+ adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
28
+ s.mount('http://', adapter)
29
+ s.mount('https://', adapter)
30
+ self.connector._session = s
31
+ def close(self):
32
+ if self.connector._session is not None:
33
+ self.connector._session.close()
34
+ self.connector._session = None
35
+ def __call__(self):
36
+ return self.connector
37
+ def __enter__(self):
38
+ self.open()
39
+ return self.connector
40
+ def __exit__(self, exc_type, exc_value, traceback):
41
+ self.close()
42
+
43
+ def __init__(self, endpoint=_default_endpoint, token=_default_token):
44
+ assert token, "No token provided. Please set LFSS_TOKEN environment variable."
45
+ self.config = {
46
+ "endpoint": endpoint,
47
+ "token": token
48
+ }
49
+ self._session: Optional[requests.Session] = None
50
+
51
+ def session(self, pool_size: int = 10):
52
+ """ avoid creating a new session for each request. """
53
+ return self.Session(self, pool_size)
54
+
55
+ def _fetch_factory(
56
+ self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
57
+ path: str, search_params: dict = {}
58
+ ):
59
+ if path.startswith('/'):
60
+ path = path[1:]
61
+ path = ensure_uri_compnents(path)
62
+ def f(**kwargs):
63
+ url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
64
+ headers: dict = kwargs.pop('headers', {})
65
+ headers.update({
66
+ 'Authorization': f"Bearer {self.config['token']}",
67
+ })
68
+ if self._session is not None:
69
+ response = self._session.request(method, url, headers=headers, **kwargs)
70
+ response.raise_for_status()
71
+ else:
72
+ with requests.Session() as s:
73
+ response = s.request(method, url, headers=headers, **kwargs)
74
+ response.raise_for_status()
75
+ return response
76
+ return f
77
+
78
+ def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
79
+ """Uploads a file to the specified path."""
80
+ assert isinstance(file_data, bytes), "file_data must be bytes"
81
+
82
+ # Skip ahead by checking if the file already exists
83
+ if conflict == 'skip-ahead':
84
+ exists = self.get_metadata(path)
85
+ if exists is None:
86
+ conflict = 'skip'
87
+ else:
88
+ return {'status': 'skipped', 'path': path}
89
+
90
+ response = self._fetch_factory('PUT', path, search_params={
91
+ 'permission': int(permission),
92
+ 'conflict': conflict
93
+ })(
94
+ data=file_data,
95
+ headers={'Content-Type': 'application/octet-stream'}
96
+ )
97
+ return response.json()
98
+
99
+ def post(self, path, file: str | bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
100
+ """
101
+ Uploads a file to the specified path,
102
+ using the POST method, with form-data/multipart.
103
+ file can be a path to a file on disk, or bytes.
104
+ """
105
+
106
+ # Skip ahead by checking if the file already exists
107
+ if conflict == 'skip-ahead':
108
+ exists = self.get_metadata(path)
109
+ if exists is None:
110
+ conflict = 'skip'
111
+ else:
112
+ return {'status': 'skipped', 'path': path}
113
+
114
+ if isinstance(file, str):
115
+ assert os.path.exists(file), "File does not exist on disk"
116
+ fsize = os.path.getsize(file)
117
+
118
+ with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
119
+
120
+ if isinstance(file, bytes):
121
+ fsize = len(file)
122
+ fp.write(file)
123
+ fp.seek(0)
124
+
125
+ # https://stackoverflow.com/questions/12385179/
126
+ print(f"Uploading {fsize} bytes")
127
+ response = self._fetch_factory('POST', path, search_params={
128
+ 'permission': int(permission),
129
+ 'conflict': conflict
130
+ })(
131
+ files={'file': fp},
132
+ )
133
+ return response.json()
134
+
135
+ def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
136
+ """Uploads a JSON file to the specified path."""
137
+ assert path.endswith('.json'), "Path must end with .json"
138
+ assert isinstance(data, dict), "data must be a dict"
139
+
140
+ # Skip ahead by checking if the file already exists
141
+ if conflict == 'skip-ahead':
142
+ exists = self.get_metadata(path)
143
+ if exists is None:
144
+ conflict = 'skip'
145
+ else:
146
+ return {'status': 'skipped', 'path': path}
147
+
148
+ response = self._fetch_factory('PUT', path, search_params={
149
+ 'permission': int(permission),
150
+ 'conflict': conflict
151
+ })(
152
+ json=data,
153
+ headers={'Content-Type': 'application/json'}
154
+ )
155
+ return response.json()
156
+
157
+ def _get(self, path: str, stream: bool = False) -> Optional[requests.Response]:
158
+ try:
159
+ response = self._fetch_factory('GET', path)(stream=stream)
160
+ except requests.exceptions.HTTPError as e:
161
+ if e.response.status_code == 404:
162
+ return None
163
+ raise e
164
+ return response
165
+
166
+ def get(self, path: str) -> Optional[bytes]:
167
+ """Downloads a file from the specified path."""
168
+ response = self._get(path)
169
+ if response is None: return None
170
+ return response.content
171
+
172
+ def get_stream(self, path: str) -> Iterator[bytes]:
173
+ """Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
174
+ response = self._get(path, stream=True)
175
+ if response is None: raise PathNotFoundError("Path not found: " + path)
176
+ return response.iter_content(chunk_size=1024)
177
+
178
+ def get_json(self, path: str) -> Optional[dict]:
179
+ response = self._get(path)
180
+ if response is None: return None
181
+ assert response.headers['Content-Type'] == 'application/json'
182
+ return response.json()
183
+
184
+ def delete(self, path: str):
185
+ """Deletes the file at the specified path."""
186
+ self._fetch_factory('DELETE', path)()
187
+
188
+ def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
189
+ """Gets the metadata for the file at the specified path."""
190
+ try:
191
+ response = self._fetch_factory('GET', '_api/meta', {'path': path})()
192
+ if path.endswith('/'):
193
+ return DirectoryRecord(**response.json())
194
+ else:
195
+ return FileRecord(**response.json())
196
+ except requests.exceptions.HTTPError as e:
197
+ if e.response.status_code == 404:
198
+ return None
199
+ raise e
200
+
201
+ def list_path(self, path: str) -> PathContents:
202
+ """
203
+ shorthand list with limited options,
204
+ for large directories / more options, use list_files and list_dirs instead.
205
+ """
206
+ assert path.endswith('/')
207
+ response = self._fetch_factory('GET', path)()
208
+ dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
209
+ files = [FileRecord(**f) for f in response.json()['files']]
210
+ return PathContents(dirs=dirs, files=files)
211
+
212
+ def count_files(self, path: str, flat: bool = False) -> int:
213
+ assert path.endswith('/')
214
+ response = self._fetch_factory('GET', '_api/count-files', {'path': path, 'flat': flat})()
215
+ return response.json()['count']
216
+
217
+ def list_files(
218
+ self, path: str, offset: int = 0, limit: int = 1000,
219
+ order_by: FileSortKey = '', order_desc: bool = False,
220
+ flat: bool = False
221
+ ) -> list[FileRecord]:
222
+ assert path.endswith('/')
223
+ response = self._fetch_factory('GET', "_api/list-files", {
224
+ 'path': path,
225
+ 'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'flat': flat
226
+ })()
227
+ return [FileRecord(**f) for f in response.json()]
228
+
229
+ def count_dirs(self, path: str) -> int:
230
+ assert path.endswith('/')
231
+ response = self._fetch_factory('GET', '_api/count-dirs', {'path': path})()
232
+ return response.json()['count']
233
+
234
+ def list_dirs(
235
+ self, path: str, offset: int = 0, limit: int = 1000,
236
+ order_by: DirSortKey = '', order_desc: bool = False,
237
+ skim: bool = True
238
+ ) -> list[DirectoryRecord]:
239
+ assert path.endswith('/')
240
+ response = self._fetch_factory('GET', "_api/list-dirs", {
241
+ 'path': path,
242
+ 'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'skim': skim
243
+ })()
244
+ return [DirectoryRecord(**d) for d in response.json()]
245
+
246
+ def set_file_permission(self, path: str, permission: int | FileReadPermission):
247
+ """Sets the file permission for the specified path."""
248
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
249
+ headers={'Content-Type': 'application/www-form-urlencoded'}
250
+ )
251
+
252
+ def move(self, path: str, new_path: str):
253
+ """Move file or directory to a new path."""
254
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
255
+ headers = {'Content-Type': 'application/www-form-urlencoded'}
256
+ )
257
+
258
+ def whoami(self) -> UserRecord:
259
+ """Gets information about the current user."""
260
+ response = self._fetch_factory('GET', '_api/whoami')()
261
+ return UserRecord(**response.json())
lfss/cli/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- from lfss.client import Connector, upload_directory, upload_file, download_file, download_directory
1
+ from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
2
2
  from pathlib import Path
3
3
  import argparse
4
4
  from lfss.src.datatype import FileReadPermission
lfss/cli/user.py CHANGED
@@ -29,7 +29,7 @@ async def _main():
29
29
  sp_set.add_argument('username', type=str)
30
30
  sp_set.add_argument('-p', '--password', type=str, default=None)
31
31
  sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
32
- sp_set.add_argument('--permission', type=int, default=None)
32
+ sp_set.add_argument('--permission', type=parse_permission, default=None)
33
33
  sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
34
34
 
35
35
  sp_list = sp.add_parser('list')
lfss/src/config.py CHANGED
@@ -18,7 +18,7 @@ if __env_large_file is not None:
18
18
  LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
19
19
  else:
20
20
  LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
21
- MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
21
+ MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
22
22
  MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
23
23
  CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
24
24
 
@@ -46,7 +46,7 @@ class SqlConnection:
46
46
 
47
47
  class SqlConnectionPool:
48
48
  _r_sem: Semaphore
49
- _w_sem: Semaphore
49
+ _w_sem: Lock | Semaphore
50
50
  def __init__(self):
51
51
  self._readers: list[SqlConnection] = []
52
52
  self._writer: None | SqlConnection = None
@@ -57,7 +57,8 @@ class SqlConnectionPool:
57
57
  self._readers = []
58
58
 
59
59
  self._writer = SqlConnection(await get_connection(read_only=False))
60
- self._w_sem = Semaphore(1)
60
+ self._w_sem = Lock()
61
+ # self._w_sem = Semaphore(1)
61
62
 
62
63
  for _ in range(n_read):
63
64
  conn = await get_connection(read_only=True)