fileglancer 0.2.0__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.
- fileglancer/__init__.py +32 -0
- fileglancer/_version.py +4 -0
- fileglancer/app.py +93 -0
- fileglancer/filestore.py +278 -0
- fileglancer/handlers.py +490 -0
- fileglancer/labextension/build_log.json +732 -0
- fileglancer/labextension/package.json +118 -0
- fileglancer/labextension/static/lib_src_index_js.a6a778b640dccfd3c15f.js +70 -0
- fileglancer/labextension/static/lib_src_index_js.a6a778b640dccfd3c15f.js.map +1 -0
- fileglancer/labextension/static/remoteEntry.98df9ae205e55c14abe8.js +551 -0
- fileglancer/labextension/static/remoteEntry.98df9ae205e55c14abe8.js.map +1 -0
- fileglancer/labextension/static/style.js +4 -0
- fileglancer/labextension/static/style_index_css.46b2a49d8245bc7a4b7c.js +471 -0
- fileglancer/labextension/static/style_index_css.46b2a49d8245bc7a4b7c.js.map +1 -0
- fileglancer/paths.py +152 -0
- fileglancer/preferences.py +251 -0
- fileglancer/tests/__init__.py +1 -0
- fileglancer/tests/test_filestore.py +171 -0
- fileglancer/tests/test_handlers.py +49 -0
- fileglancer/tests/test_mock_server.py +63 -0
- fileglancer/ui/assets/blosc-E49GQuAK.js +18 -0
- fileglancer/ui/assets/blosc-E49GQuAK.js.map +1 -0
- fileglancer/ui/assets/chunk-INHXZS53-D3tQiqtZ.js +2 -0
- fileglancer/ui/assets/chunk-INHXZS53-D3tQiqtZ.js.map +1 -0
- fileglancer/ui/assets/index-CpLWWuWv.css +1 -0
- fileglancer/ui/assets/index-DvuWJ9tx.js +96 -0
- fileglancer/ui/assets/index-DvuWJ9tx.js.map +1 -0
- fileglancer/ui/assets/lz4-BIGKWw27.js +16 -0
- fileglancer/ui/assets/lz4-BIGKWw27.js.map +1 -0
- fileglancer/ui/assets/zstd-IvP746pw.js +16 -0
- fileglancer/ui/assets/zstd-IvP746pw.js.map +1 -0
- fileglancer/ui/index.html +14 -0
- fileglancer/ui/logo.svg +101 -0
- fileglancer-0.2.0.data/data/etc/jupyter/jupyter_server_config.d/fileglancer.json +7 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/build_log.json +732 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/install.json +5 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/package.json +118 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/lib_src_index_js.a6a778b640dccfd3c15f.js +70 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/lib_src_index_js.a6a778b640dccfd3c15f.js.map +1 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/remoteEntry.98df9ae205e55c14abe8.js +551 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/remoteEntry.98df9ae205e55c14abe8.js.map +1 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/style.js +4 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/style_index_css.46b2a49d8245bc7a4b7c.js +471 -0
- fileglancer-0.2.0.data/data/share/jupyter/labextensions/fileglancer/static/style_index_css.46b2a49d8245bc7a4b7c.js.map +1 -0
- fileglancer-0.2.0.dist-info/METADATA +177 -0
- fileglancer-0.2.0.dist-info/RECORD +48 -0
- fileglancer-0.2.0.dist-info/WHEEL +4 -0
- fileglancer-0.2.0.dist-info/licenses/LICENSE +29 -0
fileglancer/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Initialize the backend server extension"""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
except ImportError:
|
|
6
|
+
# Fallback when using the package in dev mode without installing
|
|
7
|
+
# in editable mode with pip. It is highly recommended to install
|
|
8
|
+
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
|
9
|
+
import warnings
|
|
10
|
+
warnings.warn("Importing 'fileglancer' outside a proper installation.")
|
|
11
|
+
__version__ = "dev"
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from fileglancer.app import Fileglancer
|
|
15
|
+
|
|
16
|
+
def _jupyter_labextension_paths():
|
|
17
|
+
return [{
|
|
18
|
+
"src": "labextension",
|
|
19
|
+
"dest": "fileglancer"
|
|
20
|
+
}]
|
|
21
|
+
|
|
22
|
+
def _jupyter_server_extension_points():
|
|
23
|
+
"""
|
|
24
|
+
Returns a list of dictionaries with metadata describing
|
|
25
|
+
where to find the `_load_jupyter_server_extension` function.
|
|
26
|
+
"""
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
"module": "fileglancer.app",
|
|
30
|
+
"app": Fileglancer
|
|
31
|
+
}
|
|
32
|
+
]
|
fileglancer/_version.py
ADDED
fileglancer/app.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from jupyter_server.extension.application import ExtensionApp
|
|
2
|
+
from fileglancer.handlers import (
|
|
3
|
+
FileSharePathsHandler,
|
|
4
|
+
FileShareHandler,
|
|
5
|
+
VersionHandler,
|
|
6
|
+
StaticHandler,
|
|
7
|
+
PreferencesHandler,
|
|
8
|
+
TicketHandler,
|
|
9
|
+
)
|
|
10
|
+
from pathlib import Path, PurePath
|
|
11
|
+
from traitlets import (
|
|
12
|
+
TraitType,
|
|
13
|
+
Undefined,
|
|
14
|
+
Unicode,
|
|
15
|
+
Bool,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class PathType(TraitType):
|
|
19
|
+
"""A pathlib traitlet type which allows string and undefined values."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def info_text(self):
|
|
23
|
+
return 'a pathlib.PurePath object'
|
|
24
|
+
|
|
25
|
+
def validate(self, obj, value):
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
return Path(value).expanduser()
|
|
28
|
+
if isinstance(value, PurePath):
|
|
29
|
+
return value
|
|
30
|
+
if value == Undefined:
|
|
31
|
+
return value
|
|
32
|
+
self.error(obj, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Fileglancer(ExtensionApp):
|
|
36
|
+
|
|
37
|
+
name = "fileglancer"
|
|
38
|
+
app_name = "fileglancer-server"
|
|
39
|
+
load_other_extensions = True
|
|
40
|
+
default_url = "/fg/"
|
|
41
|
+
|
|
42
|
+
ui_path = PathType(
|
|
43
|
+
default_value=Path(__file__).parent / "ui",
|
|
44
|
+
config=False,
|
|
45
|
+
help="Path to the UI files.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
central_url = Unicode(
|
|
49
|
+
None,
|
|
50
|
+
allow_none=True,
|
|
51
|
+
config=True,
|
|
52
|
+
help="The URL of the central server",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
dev_mode = Bool(
|
|
56
|
+
default_value=False,
|
|
57
|
+
config=True,
|
|
58
|
+
help="Enable development mode.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def initialize_settings(self):
|
|
62
|
+
"""Update extension settings.
|
|
63
|
+
|
|
64
|
+
Update the self.settings trait to pass extra settings to the underlying
|
|
65
|
+
Tornado Web Application.
|
|
66
|
+
|
|
67
|
+
self.settings.update({'<trait>':...})
|
|
68
|
+
"""
|
|
69
|
+
super().initialize_settings()
|
|
70
|
+
|
|
71
|
+
# startup messages
|
|
72
|
+
self.log.info("Starting Fileglancer server...")
|
|
73
|
+
self.log.info(f'Serving UI from: {self.ui_path}')
|
|
74
|
+
self.log.debug(
|
|
75
|
+
'FileGlancerServer config:\n' + '\n'.join(
|
|
76
|
+
f' * {key} = {repr(value)}'
|
|
77
|
+
for key, value in self.config['Fileglancer'].items()
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def initialize_handlers(self):
|
|
82
|
+
self.handlers.extend([
|
|
83
|
+
(r"/api/fileglancer/file-share-paths", FileSharePathsHandler),
|
|
84
|
+
(r"/api/fileglancer/files/(.*)", FileShareHandler),
|
|
85
|
+
(r"/api/fileglancer/files", FileShareHandler),
|
|
86
|
+
(r"/api/fileglancer/version", VersionHandler),
|
|
87
|
+
(r"/api/fileglancer/preference", PreferencesHandler),
|
|
88
|
+
(r"/api/fileglancer/ticket", TicketHandler),
|
|
89
|
+
(r"/fg/(.*)", StaticHandler, {
|
|
90
|
+
"path": str(self.ui_path),
|
|
91
|
+
"default_filename": "index.html",
|
|
92
|
+
}),
|
|
93
|
+
])
|
fileglancer/filestore.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A module that provides a simple interface for interacting with a file system,
|
|
3
|
+
rooted at a specific directory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from typing import Optional, Generator
|
|
10
|
+
import stat
|
|
11
|
+
import pwd
|
|
12
|
+
import grp
|
|
13
|
+
import logging
|
|
14
|
+
import shutil
|
|
15
|
+
|
|
16
|
+
from fileglancer.paths import FileSharePath
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("tornado.application")
|
|
19
|
+
|
|
20
|
+
DEFAULT_BUFFER_SIZE = 8192
|
|
21
|
+
|
|
22
|
+
class FileInfo(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
A class that represents a file or directory in a Filestore.
|
|
25
|
+
"""
|
|
26
|
+
name: str
|
|
27
|
+
path: Optional[str] = None
|
|
28
|
+
size: int
|
|
29
|
+
is_dir: bool
|
|
30
|
+
permissions: str
|
|
31
|
+
owner: Optional[str] = None
|
|
32
|
+
group: Optional[str] = None
|
|
33
|
+
last_modified: Optional[float] = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_stat(cls, path: str, full_path: str, stat_result: os.stat_result):
|
|
37
|
+
"""Create FileInfo from os.stat_result"""
|
|
38
|
+
is_dir = stat.S_ISDIR(stat_result.st_mode)
|
|
39
|
+
size = 0 if is_dir else stat_result.st_size
|
|
40
|
+
name = os.path.basename(full_path)
|
|
41
|
+
permissions = stat.filemode(stat_result.st_mode)
|
|
42
|
+
last_modified = stat_result.st_mtime
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
owner = pwd.getpwuid(stat_result.st_uid).pw_name
|
|
46
|
+
except KeyError:
|
|
47
|
+
# If the user ID is not found, use the user ID as the owner
|
|
48
|
+
owner = str(stat_result.st_uid)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
group = grp.getgrgid(stat_result.st_gid).gr_name
|
|
52
|
+
except KeyError:
|
|
53
|
+
# If the group ID is not found, use the group ID as the group
|
|
54
|
+
group = str(stat_result.st_gid)
|
|
55
|
+
|
|
56
|
+
return cls(
|
|
57
|
+
name=name,
|
|
58
|
+
path=path,
|
|
59
|
+
size=size,
|
|
60
|
+
is_dir=is_dir,
|
|
61
|
+
permissions=permissions,
|
|
62
|
+
owner=owner,
|
|
63
|
+
group=group,
|
|
64
|
+
last_modified=last_modified
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Filestore:
|
|
69
|
+
"""
|
|
70
|
+
A class that provides a simple interface for interacting with a file system,
|
|
71
|
+
rooted at a specific directory.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, file_share_path: FileSharePath):
|
|
75
|
+
"""
|
|
76
|
+
Create a Filestore with the given root path.
|
|
77
|
+
"""
|
|
78
|
+
self.root_path = os.path.abspath(file_share_path.mount_path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_path_in_root(self, path: str) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Check if a path is within the root directory and return the full path.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
path (str): The relative path to check.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
str: The full path to the file or directory.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If path attempts to escape root directory
|
|
93
|
+
"""
|
|
94
|
+
if path is None or path == "":
|
|
95
|
+
full_path = self.root_path
|
|
96
|
+
else:
|
|
97
|
+
full_path = os.path.abspath(os.path.join(self.root_path, path))
|
|
98
|
+
if not full_path.startswith(self.root_path):
|
|
99
|
+
raise ValueError(f"Path ({full_path}) attempts to escape root directory ({self.root_path})")
|
|
100
|
+
return full_path
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_root_path(self) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Get the root path of the Filestore.
|
|
106
|
+
"""
|
|
107
|
+
return self.root_path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_file_info(self, path: Optional[str] = None) -> FileInfo:
|
|
111
|
+
"""
|
|
112
|
+
Get the FileInfo for a file or directory at the given path.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
path (str): The relative path to the file or directory to get the FileInfo for.
|
|
116
|
+
May be None, in which case the root directory is used.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If path attempts to escape root directory
|
|
120
|
+
"""
|
|
121
|
+
full_path = self._check_path_in_root(path)
|
|
122
|
+
stat_result = os.stat(full_path)
|
|
123
|
+
return FileInfo.from_stat(path, full_path,stat_result)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def yield_file_infos(self, path: Optional[str] = None) -> Generator[FileInfo, None, None]:
|
|
127
|
+
"""
|
|
128
|
+
Yield a FileInfo object for each child of the given path.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path (str): The relative path to the directory to list.
|
|
132
|
+
May be None, in which case the root directory is listed.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If path attempts to escape root directory
|
|
136
|
+
"""
|
|
137
|
+
full_path = self._check_path_in_root(path)
|
|
138
|
+
try:
|
|
139
|
+
for entry in os.listdir(full_path):
|
|
140
|
+
entry_path = os.path.join(full_path, entry)
|
|
141
|
+
try:
|
|
142
|
+
stat_result = os.stat(entry_path)
|
|
143
|
+
rel_entry_path = os.path.relpath(entry_path, self.root_path)
|
|
144
|
+
file_info = FileInfo.from_stat(rel_entry_path, entry_path, stat_result)
|
|
145
|
+
yield file_info
|
|
146
|
+
except (FileNotFoundError, PermissionError):
|
|
147
|
+
continue
|
|
148
|
+
except (FileNotFoundError, PermissionError):
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def stream_file_contents(self, path: str, buffer_size: int = DEFAULT_BUFFER_SIZE) -> Generator[bytes, None, None]:
|
|
153
|
+
"""
|
|
154
|
+
Stream the contents of a file at the given path.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path (str): The path to the file to stream.
|
|
158
|
+
buffer_size (int): The size of the buffer to use when reading the file.
|
|
159
|
+
Defaults to DEFAULT_BUFFER_SIZE, which is 8192 bytes.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ValueError: If path attempts to escape root directory
|
|
163
|
+
"""
|
|
164
|
+
if path is None or path == "":
|
|
165
|
+
raise ValueError("Path cannot be None or empty")
|
|
166
|
+
full_path = self._check_path_in_root(path)
|
|
167
|
+
with open(full_path, 'rb') as file:
|
|
168
|
+
while True:
|
|
169
|
+
chunk = file.read(buffer_size)
|
|
170
|
+
if not chunk:
|
|
171
|
+
break
|
|
172
|
+
yield chunk
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def rename_file_or_dir(self, old_path: str, new_path: str):
|
|
176
|
+
"""
|
|
177
|
+
Rename a file at the given old path to the new path.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
old_path (str): The relative path to the file to rename.
|
|
181
|
+
new_path (str): The new relative path for the file.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
ValueError: If either path attempts to escape root directory
|
|
185
|
+
"""
|
|
186
|
+
if old_path is None or old_path == "":
|
|
187
|
+
raise ValueError("Old path cannot be None or empty")
|
|
188
|
+
if new_path is None or new_path == "":
|
|
189
|
+
raise ValueError("New path cannot be None or empty")
|
|
190
|
+
full_old_path = self._check_path_in_root(old_path)
|
|
191
|
+
full_new_path = self._check_path_in_root(new_path)
|
|
192
|
+
os.rename(full_old_path, full_new_path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove_file_or_dir(self, path: str):
|
|
196
|
+
"""
|
|
197
|
+
Delete a file or (empty) directory at the given path.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
path (str): The relative path to the file to delete.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: If path is None or empty, or attempts to escape root directory
|
|
204
|
+
"""
|
|
205
|
+
if path is None or path == "":
|
|
206
|
+
raise ValueError("Path cannot be None or empty")
|
|
207
|
+
full_path = self._check_path_in_root(path)
|
|
208
|
+
if os.path.isdir(full_path):
|
|
209
|
+
shutil.rmtree(full_path)
|
|
210
|
+
else:
|
|
211
|
+
os.remove(full_path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_dir(self, path: str):
|
|
215
|
+
"""
|
|
216
|
+
Create a directory at the given path.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
path (str): The relative path to the directory to create.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
ValueError: If path is None or empty, or attempts to escape root directory
|
|
223
|
+
"""
|
|
224
|
+
if path is None or path == "":
|
|
225
|
+
raise ValueError("Path cannot be None or empty")
|
|
226
|
+
full_path = self._check_path_in_root(path)
|
|
227
|
+
os.mkdir(full_path)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def create_empty_file(self, path: str):
|
|
231
|
+
"""
|
|
232
|
+
Create an empty file at the given path.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
path (str): The relative path to the file to create.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: If path is None or empty, or attempts to escape root directory
|
|
239
|
+
"""
|
|
240
|
+
if path is None or path == "":
|
|
241
|
+
raise ValueError("Path cannot be None or empty")
|
|
242
|
+
full_path = self._check_path_in_root(path)
|
|
243
|
+
open(full_path, 'w').close()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def change_file_permissions(self, path: str, permissions: str):
|
|
247
|
+
"""
|
|
248
|
+
Change the permissions of a file at the given path.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
path (str): The relative path to the file to change the permissions of.
|
|
252
|
+
permissions (str): The new permissions to set for the file.
|
|
253
|
+
Must be a string of length 10, like '-rw-r--r--'.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If path is None or empty, or attempts to escape root directory,
|
|
257
|
+
or permissions is not a string of length 10.
|
|
258
|
+
"""
|
|
259
|
+
if path is None or path == "":
|
|
260
|
+
raise ValueError("Path cannot be None or empty")
|
|
261
|
+
if len(permissions) != 10:
|
|
262
|
+
raise ValueError("Permissions must be a string of length 10")
|
|
263
|
+
full_path = self._check_path_in_root(path)
|
|
264
|
+
# Convert permission string (like '-rw-r--r--') to octal mode
|
|
265
|
+
mode = 0
|
|
266
|
+
# Owner permissions (positions 1-3)
|
|
267
|
+
if permissions[1] == 'r': mode |= stat.S_IRUSR
|
|
268
|
+
if permissions[2] == 'w': mode |= stat.S_IWUSR
|
|
269
|
+
if permissions[3] == 'x': mode |= stat.S_IXUSR
|
|
270
|
+
# Group permissions (positions 4-6)
|
|
271
|
+
if permissions[4] == 'r': mode |= stat.S_IRGRP
|
|
272
|
+
if permissions[5] == 'w': mode |= stat.S_IWGRP
|
|
273
|
+
if permissions[6] == 'x': mode |= stat.S_IXGRP
|
|
274
|
+
# Other permissions (positions 7-9)
|
|
275
|
+
if permissions[7] == 'r': mode |= stat.S_IROTH
|
|
276
|
+
if permissions[8] == 'w': mode |= stat.S_IWOTH
|
|
277
|
+
if permissions[9] == 'x': mode |= stat.S_IXOTH
|
|
278
|
+
os.chmod(full_path, mode)
|