private-files 1.0.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.
- private_files/__init__.py +514 -0
- private_files/py.typed +0 -0
- private_files-1.0.0.dist-info/METADATA +273 -0
- private_files-1.0.0.dist-info/RECORD +7 -0
- private_files-1.0.0.dist-info/WHEEL +4 -0
- private_files-1.0.0.dist-info/entry_points.txt +4 -0
- private_files-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Support for management of semnsitiive user-wide application files such as authentication tokens and profile data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from functools import cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any, BinaryIO, Final, Literal, TextIO, TypeAlias, overload
|
|
13
|
+
|
|
14
|
+
from platformdirs import user_data_dir
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"OpenTextMode",
|
|
18
|
+
"OpenBinaryMode",
|
|
19
|
+
"get_shared_private_dir",
|
|
20
|
+
"create_shared_private_dir",
|
|
21
|
+
"PrivateFilesManager",
|
|
22
|
+
"get_private_files_manager",
|
|
23
|
+
"get_private_app_dir",
|
|
24
|
+
"create_private_app_dir",
|
|
25
|
+
"get_private_dir",
|
|
26
|
+
"create_private_dir",
|
|
27
|
+
"delete_private_dir",
|
|
28
|
+
"verify_private_dir",
|
|
29
|
+
"get_private_app_file",
|
|
30
|
+
"open_private_app_file",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
UNIX_PRIVATE_DIR_ROOT_PATH: Final[Path] = Path("~/.private")
|
|
34
|
+
|
|
35
|
+
OpenTextMode: TypeAlias = Literal[
|
|
36
|
+
"r", "rt", "r+", "r+t", "rt+",
|
|
37
|
+
"w", "wt", "w+", "w+t", "wt+",
|
|
38
|
+
"a", "at", "a+", "a+t", "at+",
|
|
39
|
+
"x", "xt", "x+", "x+t", "xt+",
|
|
40
|
+
]
|
|
41
|
+
"""Mode strings for open() calls that produce a TextIO."""
|
|
42
|
+
|
|
43
|
+
OpenBinaryMode: TypeAlias = Literal[
|
|
44
|
+
"rb", "r+b", "rb+",
|
|
45
|
+
"wb", "w+b", "wb+",
|
|
46
|
+
"ab", "a+b", "ab+",
|
|
47
|
+
"xb", "x+b", "xb+",
|
|
48
|
+
]
|
|
49
|
+
"""Mode strings for open() calls that produce a BinaryIO."""
|
|
50
|
+
|
|
51
|
+
@cache
|
|
52
|
+
def get_shared_private_dir() -> Path:
|
|
53
|
+
"""Get the name of the shared user-wide private root directory for storing sensitive data like authentication tokens.
|
|
54
|
+
On linux and macos, this will be ~/.private, which the user can choose to encrypt or protect as needed.
|
|
55
|
+
On Windows, this will be the non-roaming app data directory.
|
|
56
|
+
|
|
57
|
+
Does not create the directory or guarantee any particular permissions, so the returned directory
|
|
58
|
+
may not be safe for storing sensitive data until create_private_dir has been called.
|
|
59
|
+
"""
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
# On Windows, use a well-known subdir of the non-roaming app data directory,
|
|
62
|
+
# which is not backed up to the cloud and is not shared across devices.
|
|
63
|
+
return Path(user_data_dir("private_files", roaming=False)).resolve()
|
|
64
|
+
else:
|
|
65
|
+
# On Linux and MacOS, use ~/.private, which the user can choose to encrypt or protect as needed
|
|
66
|
+
return UNIX_PRIVATE_DIR_ROOT_PATH.expanduser().resolve()
|
|
67
|
+
|
|
68
|
+
@cache
|
|
69
|
+
def create_shared_private_dir() -> Path:
|
|
70
|
+
"""Create and return the shared user-wide private root directory for storing sensitive data like authentication tokens,
|
|
71
|
+
if it does not already exist. On linux and macos, this will be ~/.private, which the user can choose to encrypt or protect as needed.
|
|
72
|
+
On Windows, this will be the non-roaming app data directory.
|
|
73
|
+
|
|
74
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
75
|
+
"""
|
|
76
|
+
private_dir = get_shared_private_dir()
|
|
77
|
+
|
|
78
|
+
# create the ~/.private parent directory (or equivalent windows dir) with mode 0700 if necessary, and ensure permissions are correct.
|
|
79
|
+
old_umask = os.umask(0o077)
|
|
80
|
+
try:
|
|
81
|
+
os.makedirs(private_dir, mode=0o700, exist_ok=True)
|
|
82
|
+
finally:
|
|
83
|
+
os.umask(old_umask)
|
|
84
|
+
if not private_dir.is_dir():
|
|
85
|
+
raise NotADirectoryError(f"Expected {str(private_dir)!r} to be a directory, but it is not.")
|
|
86
|
+
if sys.platform != "win32":
|
|
87
|
+
current_mode = private_dir.stat().st_mode & 0o777
|
|
88
|
+
if current_mode != 0o700:
|
|
89
|
+
# For the shared ~/.private directory, we do not automatically fix the permissions, since it may be shared with
|
|
90
|
+
# other applications.
|
|
91
|
+
# But we do require that it is locked down.
|
|
92
|
+
raise PermissionError(
|
|
93
|
+
f"Expected {str(private_dir)!r} to have permissions 0700, but it has permissions {current_mode:04o}. "
|
|
94
|
+
"Please set the permissions to 0700 to protect your sensitive data."
|
|
95
|
+
)
|
|
96
|
+
return private_dir
|
|
97
|
+
|
|
98
|
+
class PrivateFilesManager:
|
|
99
|
+
"""A class for managing private files and directories for an application."""
|
|
100
|
+
|
|
101
|
+
app_name: str | None
|
|
102
|
+
"""The application name used to create the application-specific private directory.
|
|
103
|
+
If None, a shared private parent directory not specific to an application is used.."""
|
|
104
|
+
|
|
105
|
+
_shared_root_dir: Path | None = None
|
|
106
|
+
"""The cached shared private directory root path, if it has been computed."""
|
|
107
|
+
|
|
108
|
+
_shared_root_dir_created: bool = False
|
|
109
|
+
"""Whether the shared private directory root path has been created."""
|
|
110
|
+
|
|
111
|
+
_root_dir: Path | None = None
|
|
112
|
+
"""The cached application-specific private directory root path, if it has been computed."""
|
|
113
|
+
|
|
114
|
+
_root_dir_created: bool = False
|
|
115
|
+
"""Whether the application-specific private directory root path has been created."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, app_name: str | None = None):
|
|
118
|
+
self.app_name = app_name
|
|
119
|
+
|
|
120
|
+
def get_shared_root_dir(self) -> Path:
|
|
121
|
+
"""Get the name of the shared user-wide private root directory for storing sensitive data like authentication tokens.
|
|
122
|
+
On linux and macos, this will be ~/.private, which the user can choose to encrypt or protect as needed.
|
|
123
|
+
On Windows, this will be the non-roaming app data directory.
|
|
124
|
+
|
|
125
|
+
Does not create the directory or guarantee any particular permissions, so the returned directory
|
|
126
|
+
may not be safe for storing sensitive data until create_private_dir has been called.
|
|
127
|
+
"""
|
|
128
|
+
if self._shared_root_dir is None:
|
|
129
|
+
self._shared_root_dir = get_shared_private_dir()
|
|
130
|
+
return self._shared_root_dir
|
|
131
|
+
|
|
132
|
+
def create_shared_root_dir(self) -> Path:
|
|
133
|
+
"""Create and return the shared user-wide private root directory for storing sensitive data
|
|
134
|
+
like authentication tokens, if it does not already exist. On linux and macos, this will be
|
|
135
|
+
~/.private, which the user can choose to encrypt or protect as needed.
|
|
136
|
+
On Windows, this will be the non-roaming app data directory.
|
|
137
|
+
|
|
138
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
139
|
+
"""
|
|
140
|
+
shared_root_dir = self.get_shared_root_dir()
|
|
141
|
+
if not self._shared_root_dir_created:
|
|
142
|
+
create_shared_private_dir()
|
|
143
|
+
self._shared_root_dir_created = True
|
|
144
|
+
return shared_root_dir
|
|
145
|
+
|
|
146
|
+
def get_root_dir(self) -> Path:
|
|
147
|
+
"""Get the name of the application-specific user-wide private root directory for storing sensitive data like authentication tokens.
|
|
148
|
+
On linux and macos, this will be a directory under ~/.private, which the user can choose to encrypt or protect as needed.
|
|
149
|
+
On Windows, this will be the non-roaming app data directory.
|
|
150
|
+
|
|
151
|
+
Does not create the directory or guarantee any particular permissions, so the returned directory
|
|
152
|
+
may not be safe for storing sensitive data until create_private_dir has been called.
|
|
153
|
+
"""
|
|
154
|
+
app_name = self.app_name
|
|
155
|
+
if self._root_dir is None:
|
|
156
|
+
shared_root_dir = self.get_shared_root_dir()
|
|
157
|
+
root_dir = (shared_root_dir / (app_name if app_name else ".")).resolve()
|
|
158
|
+
if not root_dir.is_relative_to(shared_root_dir):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Expected application-specific private directory {str(app_name)!r} to resolve to a path "
|
|
161
|
+
f"within the shared private directory {str(self.get_shared_root_dir())!r}, but it does not."
|
|
162
|
+
)
|
|
163
|
+
self._root_dir = root_dir
|
|
164
|
+
return self._root_dir
|
|
165
|
+
|
|
166
|
+
def create_root_dir(self) -> Path:
|
|
167
|
+
"""Create and return the application-specific user-wide private root directory for storing sensitive data
|
|
168
|
+
like authentication tokens, if it does not already exist. On Linux and MacOS, this will be a directory
|
|
169
|
+
under ~/.private with mode 0700. On Windows, this will be the non-roaming app data directory.
|
|
170
|
+
|
|
171
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
172
|
+
"""
|
|
173
|
+
root_dir = self.get_root_dir()
|
|
174
|
+
if not self._root_dir_created:
|
|
175
|
+
shared_root_dir = self.create_shared_root_dir()
|
|
176
|
+
if not root_dir.is_relative_to(shared_root_dir):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Expected application-specific private directory {str(root_dir)!r} to resolve to a path "
|
|
179
|
+
f"within the shared private directory {str(shared_root_dir)!r}, but it does not."
|
|
180
|
+
)
|
|
181
|
+
rel_dir = root_dir.relative_to(shared_root_dir)
|
|
182
|
+
partial_dir = shared_root_dir
|
|
183
|
+
for component in rel_dir.parts:
|
|
184
|
+
partial_dir = partial_dir / component
|
|
185
|
+
old_umask = os.umask(0o077)
|
|
186
|
+
try:
|
|
187
|
+
os.makedirs(partial_dir, mode=0o700, exist_ok=True)
|
|
188
|
+
finally:
|
|
189
|
+
os.umask(old_umask)
|
|
190
|
+
if sys.platform != "win32":
|
|
191
|
+
if not partial_dir.is_dir():
|
|
192
|
+
raise NotADirectoryError(f"Expected {str(partial_dir)!r} to be a directory, but it is not.")
|
|
193
|
+
current_mode = partial_dir.stat().st_mode & 0o777
|
|
194
|
+
if current_mode != 0o700:
|
|
195
|
+
# For our private app-specific subdir, we go ahead and fix the permissions if they are not correct,
|
|
196
|
+
partial_dir.chmod(0o700)
|
|
197
|
+
self._root_dir_created = True
|
|
198
|
+
|
|
199
|
+
return root_dir
|
|
200
|
+
|
|
201
|
+
def get_private_dir(self, subdir: str | Path) -> Path:
|
|
202
|
+
"""Return the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
203
|
+
like authentication tokens. On Linux and MacOS, this will be a directory
|
|
204
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
205
|
+
|
|
206
|
+
Verifies that the resolved path is within the app-specific private directory, but does not
|
|
207
|
+
create the directory or guarantee any particular permissions.
|
|
208
|
+
"""
|
|
209
|
+
app_dir = self.get_root_dir()
|
|
210
|
+
subdir_fullpath = (app_dir / subdir).resolve()
|
|
211
|
+
if not subdir_fullpath.is_relative_to(app_dir):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Expected private subdir {str(subdir)!r} to resolve to a path "
|
|
214
|
+
f"within the app-specific private directory {str(app_dir)!r}, but it does not."
|
|
215
|
+
)
|
|
216
|
+
return subdir_fullpath
|
|
217
|
+
|
|
218
|
+
def create_private_dir(self, subdir: str | Path) -> Path:
|
|
219
|
+
"""Create and return the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
220
|
+
like authentication tokens, if it does not already exist. On Linux and MacOS, this will be a directory
|
|
221
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
222
|
+
|
|
223
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
224
|
+
|
|
225
|
+
If subdir is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
226
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
227
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
228
|
+
"""
|
|
229
|
+
app_dir_path = self.create_root_dir()
|
|
230
|
+
subdir_fullpath = self.get_private_dir(subdir)
|
|
231
|
+
subdir_path = subdir_fullpath.relative_to(app_dir_path)
|
|
232
|
+
subdir_partial_path = app_dir_path
|
|
233
|
+
for component in subdir_path.parts:
|
|
234
|
+
subdir_partial_path = subdir_partial_path / component
|
|
235
|
+
old_umask = os.umask(0o077)
|
|
236
|
+
try:
|
|
237
|
+
os.makedirs(subdir_partial_path, mode=0o700, exist_ok=True)
|
|
238
|
+
finally:
|
|
239
|
+
os.umask(old_umask)
|
|
240
|
+
if sys.platform != "win32":
|
|
241
|
+
if not subdir_partial_path.is_dir():
|
|
242
|
+
raise NotADirectoryError(f"Expected {str(subdir_partial_path)!r} to be a directory, but it is not.")
|
|
243
|
+
current_mode = subdir_partial_path.stat().st_mode & 0o777
|
|
244
|
+
if current_mode != 0o700:
|
|
245
|
+
subdir_partial_path.chmod(0o700)
|
|
246
|
+
return subdir_fullpath
|
|
247
|
+
|
|
248
|
+
def delete_private_dir(self, subdir: str | Path) -> None:
|
|
249
|
+
"""Delete the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
250
|
+
like authentication tokens, if it exists. On Linux and MacOS, this will be a directory
|
|
251
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
252
|
+
|
|
253
|
+
If the directory does not exist, nothing happens. If the directory cannot be deleted, an exception will be raised.
|
|
254
|
+
|
|
255
|
+
If subdir_name is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
256
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
257
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
258
|
+
|
|
259
|
+
Deletion of the shared root directory itself is not allowed.
|
|
260
|
+
"""
|
|
261
|
+
subdir_fullpath = self.get_private_dir(subdir)
|
|
262
|
+
if subdir_fullpath == self.get_shared_root_dir():
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"Deletion of the shared private root directory {str(subdir_fullpath)!r} is not allowed. "
|
|
265
|
+
"Please delete application-specific ubdirectories instead."
|
|
266
|
+
)
|
|
267
|
+
if not subdir_fullpath.is_dir():
|
|
268
|
+
raise NotADirectoryError(f"Expected {str(subdir_fullpath)!r} to be a directory, but it is not.")
|
|
269
|
+
shutil.rmtree(subdir_fullpath)
|
|
270
|
+
|
|
271
|
+
def verify_private_dir(self, subdir: str | Path) -> Path:
|
|
272
|
+
"""Verify that the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
273
|
+
like authentication tokens exists and has permissions 0700. On Linux and MacOS, this will be a directory
|
|
274
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
275
|
+
|
|
276
|
+
If the directory does not exist or does not have permissions 0700, an exception will be raised.
|
|
277
|
+
|
|
278
|
+
If subdir_name is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
279
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
280
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
281
|
+
"""
|
|
282
|
+
app_dir = self.get_root_dir()
|
|
283
|
+
app_dir_parent = app_dir.parent
|
|
284
|
+
subdir_full = self.get_private_dir(subdir)
|
|
285
|
+
subdir_relpath = subdir_full.relative_to(app_dir_parent)
|
|
286
|
+
subdir_partial_path = app_dir_parent
|
|
287
|
+
for component in subdir_relpath.parts:
|
|
288
|
+
subdir_partial_path = subdir_partial_path / component
|
|
289
|
+
if not subdir_partial_path.is_dir():
|
|
290
|
+
raise NotADirectoryError(f"Expected {str(subdir_partial_path)!r} to be a directory, but it is not.")
|
|
291
|
+
if sys.platform != "win32":
|
|
292
|
+
current_mode = subdir_partial_path.stat().st_mode & 0o777
|
|
293
|
+
if current_mode != 0o700:
|
|
294
|
+
raise PermissionError(
|
|
295
|
+
f"Expected {str(subdir_partial_path)!r} to have permissions 0700, but it has permissions {current_mode:04o}. "
|
|
296
|
+
"Please set the permissions to 0700 to protect your sensitive data."
|
|
297
|
+
)
|
|
298
|
+
return subdir_full
|
|
299
|
+
|
|
300
|
+
def get_private_file(
|
|
301
|
+
self,
|
|
302
|
+
filename: str | Path,
|
|
303
|
+
create_parent: bool = False,
|
|
304
|
+
subdir: str | Path = ".",
|
|
305
|
+
) -> Path:
|
|
306
|
+
"""Get the fully qualified path to a file with the given name in the application-specific
|
|
307
|
+
user-wide private directory for storing sensitive data like authentication tokens.
|
|
308
|
+
|
|
309
|
+
subdir_name is resolved relative to the app-specific private root directory,
|
|
310
|
+
and filename is resolved relative to subdir_name. The file must resolve
|
|
311
|
+
to a file within the specified subdirectory, or a ValueError will be raised.
|
|
312
|
+
|
|
313
|
+
If create_parent is True, the parent directory/directories, up to and including the application-specific root
|
|
314
|
+
directory, will be created if they do not already exist, and their permissions will be adjusted to 0700.
|
|
315
|
+
|
|
316
|
+
If create_parent is False, the directory is not created but the existence and permissions of
|
|
317
|
+
all parent directories are verified.
|
|
318
|
+
|
|
319
|
+
The file itself is not created, and no guarantees are made about its existence or permissions."""
|
|
320
|
+
subdir_path = self.get_private_dir(subdir)
|
|
321
|
+
file_path = (subdir_path / filename).resolve()
|
|
322
|
+
if not file_path.is_relative_to(subdir_path):
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"Expected private file {str(filename)!r} to resolve to a path "
|
|
325
|
+
f"within the app-specific private directory {str(subdir_path)!r}, but it does not."
|
|
326
|
+
)
|
|
327
|
+
parent_dir = file_path.parent
|
|
328
|
+
if create_parent:
|
|
329
|
+
self.create_private_dir(parent_dir)
|
|
330
|
+
else:
|
|
331
|
+
self.verify_private_dir(parent_dir)
|
|
332
|
+
return file_path
|
|
333
|
+
|
|
334
|
+
@overload
|
|
335
|
+
def open(
|
|
336
|
+
self, filename: str | Path, mode: OpenTextMode, *, subdir: str | Path = ".", create_parent: bool = False, **kwargs: Any
|
|
337
|
+
) -> TextIO: ...
|
|
338
|
+
|
|
339
|
+
@overload
|
|
340
|
+
def open(
|
|
341
|
+
self, filename: str | Path, mode: OpenBinaryMode, *, subdir: str | Path = ".", create_parent: bool = False, **kwargs: Any
|
|
342
|
+
) -> BinaryIO: ...
|
|
343
|
+
|
|
344
|
+
@overload
|
|
345
|
+
def open(
|
|
346
|
+
self, filename: str | Path, mode: str, *, subdir: str | Path = ".", create_parent: bool = False, **kwargs: Any
|
|
347
|
+
) -> IO[Any]: ...
|
|
348
|
+
|
|
349
|
+
def open(
|
|
350
|
+
self,
|
|
351
|
+
filename: str | Path,
|
|
352
|
+
mode: str = "r", *,
|
|
353
|
+
subdir: str | Path = ".",
|
|
354
|
+
create_parent: bool = False,
|
|
355
|
+
**kwargs: Any
|
|
356
|
+
) -> IO[Any]:
|
|
357
|
+
"""Open a file with the given name in the application-specific user-wide private directory and subdirectory,
|
|
358
|
+
creating the directory if necessary. If writing to the file, ensure that it has permissions 0600
|
|
359
|
+
(readable and writable only by the user)."""
|
|
360
|
+
|
|
361
|
+
allows_create = any(m in mode for m in "wax")
|
|
362
|
+
is_write_mode = any(m in mode for m in "wax+")
|
|
363
|
+
file_path = self.get_private_file(filename, create_parent=allows_create or create_parent, subdir=subdir)
|
|
364
|
+
f = open(file_path, mode, **kwargs) # noqa: SIM115
|
|
365
|
+
try:
|
|
366
|
+
if is_write_mode and sys.platform != "win32":
|
|
367
|
+
# On Linux and MacOS, ensure the file has mode 400 (readable only by the user).
|
|
368
|
+
fd = f.fileno()
|
|
369
|
+
st = os.fstat(fd)
|
|
370
|
+
mode_bits = st.st_mode & 0o777
|
|
371
|
+
if mode_bits != 0o600:
|
|
372
|
+
os.fchmod(fd, 0o600)
|
|
373
|
+
except Exception:
|
|
374
|
+
f.close()
|
|
375
|
+
raise
|
|
376
|
+
return f
|
|
377
|
+
|
|
378
|
+
@cache
|
|
379
|
+
def get_private_files_manager(app_name: str | None = None) -> PrivateFilesManager:
|
|
380
|
+
"""Get a cached PrivateFilesManager instance for the given application name."""
|
|
381
|
+
return PrivateFilesManager(app_name=app_name)
|
|
382
|
+
|
|
383
|
+
def get_private_app_dir(app_name: str | None = None) -> Path:
|
|
384
|
+
"""Return the application-specific user-wide private directory for storing sensitive data
|
|
385
|
+
like authentication tokens. On Linux and MacOS, this will be a directory
|
|
386
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
387
|
+
"""
|
|
388
|
+
return get_private_files_manager(app_name).get_root_dir()
|
|
389
|
+
|
|
390
|
+
def create_private_app_dir(app_name: str | None = None) -> Path:
|
|
391
|
+
"""Create and return the application-specific user-wide private root directory for storing sensitive data
|
|
392
|
+
like authentication tokens, if it does not already exist. On Linux and MacOS, this will be a directory
|
|
393
|
+
under ~/.private with mode 0700. On Windows, this will be the non-roaming app data directory.
|
|
394
|
+
|
|
395
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
396
|
+
"""
|
|
397
|
+
return get_private_files_manager(app_name).create_root_dir()
|
|
398
|
+
|
|
399
|
+
def get_private_dir(subdir: str | Path, app_name: str | None = None) -> Path:
|
|
400
|
+
"""Return the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
401
|
+
like authentication tokens. On Linux and MacOS, this will be a directory
|
|
402
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
403
|
+
|
|
404
|
+
Verifies that the resolved path is within the app-specific private directory, but does not
|
|
405
|
+
create the directory or guarantee any particular permissions.
|
|
406
|
+
"""
|
|
407
|
+
return get_private_files_manager(app_name).get_private_dir(subdir)
|
|
408
|
+
|
|
409
|
+
def create_private_dir(subdir: str | Path, app_name: str | None = None) -> Path:
|
|
410
|
+
"""Create and return the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
411
|
+
like authentication tokens, if it does not already exist. On Linux and MacOS, this will be a directory
|
|
412
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
413
|
+
|
|
414
|
+
If the directory cannot be created or cannot be set to the correct permissions, an exception will be raised.
|
|
415
|
+
|
|
416
|
+
If subdir_name is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
417
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
418
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
419
|
+
"""
|
|
420
|
+
return get_private_files_manager(app_name).create_private_dir(subdir)
|
|
421
|
+
|
|
422
|
+
def delete_private_dir(subdir_name: str | Path, app_name: str) -> None:
|
|
423
|
+
"""Delete the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
424
|
+
like authentication tokens, if it exists. On Linux and MacOS, this will be a directory
|
|
425
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
426
|
+
|
|
427
|
+
If the directory does not exist, nothing happens. If the directory cannot be deleted, an exception will be raised.
|
|
428
|
+
|
|
429
|
+
If subdir_name is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
430
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
431
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
432
|
+
|
|
433
|
+
Deletion of the shared root directory itself is not allowed.
|
|
434
|
+
"""
|
|
435
|
+
subdir_fullpath = get_private_dir(subdir_name, app_name=app_name)
|
|
436
|
+
if subdir_fullpath.is_dir():
|
|
437
|
+
shutil.rmtree(subdir_fullpath)
|
|
438
|
+
|
|
439
|
+
def verify_private_dir(subdir_name: str | Path, app_name: str | None = None) -> Path:
|
|
440
|
+
"""Verify that the application-specific user-wide private directory or a subdirectory thereof for storing sensitive data
|
|
441
|
+
like authentication tokens exists and has permissions 0700. On Linux and MacOS, this will be a directory
|
|
442
|
+
under ~/.private/{app_name} with mode 0700. On Windows, this will be under the non-roaming app data directory.
|
|
443
|
+
|
|
444
|
+
If the directory does not exist or does not have permissions 0700, an exception will be raised.
|
|
445
|
+
|
|
446
|
+
If subdir_name is a relative path, it is resolved relative to the the app-specific private root directory.
|
|
447
|
+
Regardless, it must resolve to the app-specific root directory or a subdirectory of it.
|
|
448
|
+
For example, "." can be used to refer to the app-specific private root directory itself.
|
|
449
|
+
"""
|
|
450
|
+
return get_private_files_manager(app_name).verify_private_dir(subdir_name)
|
|
451
|
+
|
|
452
|
+
def get_private_app_file(
|
|
453
|
+
filename: str | Path,
|
|
454
|
+
*,
|
|
455
|
+
create_parent: bool = False,
|
|
456
|
+
subdir: str | Path = ".",
|
|
457
|
+
app_name: str | None = None,
|
|
458
|
+
) -> Path:
|
|
459
|
+
"""Get the fully qualified path to a file with the given name in the application-specific
|
|
460
|
+
user-wide private directory for storing sensitive data like authentication tokens.
|
|
461
|
+
|
|
462
|
+
subdir_name is resolved relative to the app-specific private root directory,
|
|
463
|
+
and filename is resolved relative to subdir_name. The file must resolve to a file within the
|
|
464
|
+
specified subdirectory, or a ValueError will be raised.
|
|
465
|
+
|
|
466
|
+
If create_parent is True, the parent directory/directories, up to and including the application-specific root
|
|
467
|
+
directory, will be created if they do not already exist, and their permissions will be adjusted to 0700.
|
|
468
|
+
|
|
469
|
+
If create_parent is False, the directory is not created but the existence and permissions of
|
|
470
|
+
all parent directories are verified.
|
|
471
|
+
|
|
472
|
+
The file itself is not created, and no guarantees are made about its existence or permissions."""
|
|
473
|
+
return get_private_files_manager(app_name).get_private_file(
|
|
474
|
+
filename=filename,
|
|
475
|
+
create_parent=create_parent,
|
|
476
|
+
subdir=subdir,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
@overload
|
|
480
|
+
def open_private_app_file(
|
|
481
|
+
filename: str | Path, mode: OpenTextMode, *, subdir: str | Path = ".",
|
|
482
|
+
create_parent: bool = False, app_name: str | None = None, **kwargs: Any
|
|
483
|
+
) -> TextIO: ...
|
|
484
|
+
|
|
485
|
+
@overload
|
|
486
|
+
def open_private_app_file(
|
|
487
|
+
filename: str | Path, mode: OpenBinaryMode, *, subdir: str | Path = ".",
|
|
488
|
+
create_parent: bool = False, app_name: str | None = None, **kwargs: Any
|
|
489
|
+
) -> BinaryIO: ...
|
|
490
|
+
|
|
491
|
+
@overload
|
|
492
|
+
def open_private_app_file(
|
|
493
|
+
filename: str | Path, mode: str, *, subdir: str | Path = ".",
|
|
494
|
+
create_parent: bool = False, app_name: str | None = None, **kwargs: Any
|
|
495
|
+
) -> IO[Any]: ...
|
|
496
|
+
|
|
497
|
+
def open_private_app_file(
|
|
498
|
+
filename: str | Path,
|
|
499
|
+
mode: str = "r",
|
|
500
|
+
*,
|
|
501
|
+
subdir: str | Path = ".",
|
|
502
|
+
create_parent: bool = False,
|
|
503
|
+
app_name: str | None = None,
|
|
504
|
+
**kwargs: Any
|
|
505
|
+
) -> IO[Any]:
|
|
506
|
+
"""Open a file with the given name in the application-specific user-wide private directory and subdirectory,
|
|
507
|
+
creating the directory if necessary. This is a convenience wrapper around get_private_app_file and open."""
|
|
508
|
+
return get_private_files_manager(app_name).open(
|
|
509
|
+
filename=filename,
|
|
510
|
+
mode=mode,
|
|
511
|
+
subdir=subdir,
|
|
512
|
+
create_parent=create_parent,
|
|
513
|
+
**kwargs
|
|
514
|
+
)
|
private_files/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: private-files
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Manage secret/private files in an os-independent way
|
|
5
|
+
Keywords: private,secrets,credentials,security,permissions,config,cross-platform,filesystem,dotfiles,application-data
|
|
6
|
+
Author-Email: Sam McKelvie <dev@mckelvie.org>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Samuel J. McKelvie
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
39
|
+
Classifier: Topic :: Security
|
|
40
|
+
Classifier: Topic :: System :: Filesystems
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Classifier: Typing :: Typed
|
|
43
|
+
Project-URL: Homepage, https://github.com/mckelvie-org/private-files
|
|
44
|
+
Project-URL: Source, https://github.com/mckelvie-org/private-files/tree/v1.0.0
|
|
45
|
+
Project-URL: Bug Tracker, https://github.com/mckelvie-org/private-files/issues
|
|
46
|
+
Project-URL: Changelog, https://github.com/mckelvie-org/private-files/releases
|
|
47
|
+
Requires-Python: >=3.10
|
|
48
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# private-files
|
|
52
|
+
|
|
53
|
+
[](https://github.com/mckelvie-org/private-files/actions/workflows/ci.yml)
|
|
54
|
+
[](https://pypi.org/project/private-files/1.0.0/)
|
|
55
|
+
[](https://pypi.org/project/private-files/)
|
|
56
|
+
[](LICENSE)
|
|
57
|
+
|
|
58
|
+
`private-files`: Manage secret/private files in an os-independent way.
|
|
59
|
+
|
|
60
|
+
Applications that need to persist sensitive data on a user's machine — API tokens, session cookies,
|
|
61
|
+
credentials, profile data — need somewhere to put it that isn't the world-readable home directory
|
|
62
|
+
clutter of `~/.config` or `~/.myapp`. `private-files` gives every application its own subdirectory of a
|
|
63
|
+
single, locked-down, user-wide private root, and enforces `0700`/`0600` permissions on directories and
|
|
64
|
+
files it creates so secrets are never accidentally left group- or world-readable.
|
|
65
|
+
|
|
66
|
+
## Highlights
|
|
67
|
+
|
|
68
|
+
- **One shared private root per user.** On Linux and macOS this is `~/.private`; on Windows it's the
|
|
69
|
+
non-roaming application-data directory (via [`platformdirs`](https://pypi.org/project/platformdirs/)),
|
|
70
|
+
which is not synced to the cloud or shared across devices.
|
|
71
|
+
- **Per-application subdirectories.** Each app gets its own subdirectory of the shared root, named after
|
|
72
|
+
an `app_name` you choose, so multiple applications can share the same machine without stepping on each
|
|
73
|
+
other's secrets.
|
|
74
|
+
- **Permissions are enforced, not assumed.** Directories created by this package are `chmod 0700`; files
|
|
75
|
+
opened for writing are `chmod 0600`. Existing app-specific directories with the wrong permissions are
|
|
76
|
+
fixed automatically; the shared root itself is only ever checked, never silently "fixed," since it may
|
|
77
|
+
be shared with other applications.
|
|
78
|
+
- **Path-traversal safe.** Subdirectory and filename arguments are resolved and checked to ensure they
|
|
79
|
+
stay within the intended app directory — a `subdir` or `filename` of `"../../etc/passwd"` raises
|
|
80
|
+
`ValueError` instead of silently escaping the sandbox.
|
|
81
|
+
- **Two equivalent APIs.** A `PrivateFilesManager` class for when you're working with one application
|
|
82
|
+
repeatedly (it caches the resolved paths), and a set of flat module-level functions for one-off calls,
|
|
83
|
+
both backed by the same cached manager instances.
|
|
84
|
+
- **Drop-in `open()` replacement.** `open_private_app_file()` / `PrivateFilesManager.open()` behave like
|
|
85
|
+
the builtin `open()` — including `@overload`-based mode-based return-type inference (`TextIO` vs
|
|
86
|
+
`BinaryIO`) — but resolve the path into the private directory and create parent directories and
|
|
87
|
+
fix file permissions automatically.
|
|
88
|
+
- **Fully typed**, `mypy --strict` clean, zero required dependencies beyond `platformdirs`.
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install private-files
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Quick Start
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from private_files import open_private_app_file, get_private_app_dir
|
|
100
|
+
|
|
101
|
+
# Write a secret. Parent directories are created automatically (mode 0700),
|
|
102
|
+
# and the file itself ends up with mode 0600.
|
|
103
|
+
with open_private_app_file("api-token.txt", "w", app_name="myapp") as f:
|
|
104
|
+
f.write("super-secret-token")
|
|
105
|
+
|
|
106
|
+
# Read it back later.
|
|
107
|
+
with open_private_app_file("api-token.txt", "r", app_name="myapp") as f:
|
|
108
|
+
token = f.read()
|
|
109
|
+
|
|
110
|
+
# Find out where it lives on disk, without opening it.
|
|
111
|
+
print(get_private_app_dir(app_name="myapp"))
|
|
112
|
+
# -> /home/alice/.private/myapp (Linux/macOS)
|
|
113
|
+
# -> C:\Users\alice\AppData\Local\myapp\myapp (Windows)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If your application makes several calls, prefer a `PrivateFilesManager`, which resolves and caches its
|
|
117
|
+
paths once instead of on every call:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from private_files import PrivateFilesManager
|
|
121
|
+
|
|
122
|
+
files = PrivateFilesManager(app_name="myapp")
|
|
123
|
+
|
|
124
|
+
with files.open("api-token.txt", "w") as f:
|
|
125
|
+
f.write("super-secret-token")
|
|
126
|
+
|
|
127
|
+
with files.open("session/cookies.json", "w", create_parent=True) as f:
|
|
128
|
+
f.write("{}")
|
|
129
|
+
|
|
130
|
+
files.delete_private_dir("session")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`get_private_files_manager(app_name)` returns a process-wide cached `PrivateFilesManager` for a given
|
|
134
|
+
`app_name`, which is what all of the flat module-level functions use internally — so mixing the two
|
|
135
|
+
styles for the same `app_name` shares the same cached, verified paths.
|
|
136
|
+
|
|
137
|
+
## Concepts
|
|
138
|
+
|
|
139
|
+
### Shared root vs. app-specific directory
|
|
140
|
+
|
|
141
|
+
There are two levels of directory:
|
|
142
|
+
|
|
143
|
+
- The **shared private root** is one directory per user, shared by every application using this package:
|
|
144
|
+
`~/.private` on Linux/macOS, or the non-roaming app-data directory on Windows. It must already have
|
|
145
|
+
(or be given) permissions `0700`. This package will create it if missing but will **not** silently fix
|
|
146
|
+
its permissions if it already exists with the wrong mode, since it may be shared with other
|
|
147
|
+
applications you don't control — instead it raises `PermissionError` so you can decide what to do.
|
|
148
|
+
- The **application-specific directory** is a subdirectory of the shared root named after your `app_name`
|
|
149
|
+
(e.g. `~/.private/myapp`). Unlike the shared root, this package **does** own it, so it actively enforces
|
|
150
|
+
and repairs `0700` permissions on it and any subdirectories you create within it.
|
|
151
|
+
|
|
152
|
+
Passing `app_name=None` (the default) targets the shared root itself rather than a per-application
|
|
153
|
+
subdirectory.
|
|
154
|
+
|
|
155
|
+
### Subdirectories and files
|
|
156
|
+
|
|
157
|
+
Within an application's directory you can create arbitrarily nested subdirectories (`subdir="cache/v2"`)
|
|
158
|
+
and files within them. Every directory component created by `create_private_dir()` (or implicitly by
|
|
159
|
+
`create_parent=True`) gets its permissions verified and, if necessary, corrected to `0700`. Every
|
|
160
|
+
directory checked by `verify_private_dir()` must already be `0700`, or a `PermissionError` is raised.
|
|
161
|
+
|
|
162
|
+
All `subdir` and `filename` arguments are resolved and checked against the directory they're supposed to
|
|
163
|
+
be contained within; anything that would resolve outside of it (via `..`, absolute paths outside the
|
|
164
|
+
tree, symlinks, etc.) raises `ValueError` rather than being silently permitted.
|
|
165
|
+
|
|
166
|
+
## API Reference
|
|
167
|
+
|
|
168
|
+
### `PrivateFilesManager`
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
class PrivateFilesManager:
|
|
172
|
+
def __init__(self, app_name: str | None = None): ...
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
An object bound to a single `app_name` (or `None` for the shared root) that resolves and caches its
|
|
176
|
+
directory paths across calls.
|
|
177
|
+
|
|
178
|
+
| Method | Description |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `get_shared_root_dir() -> Path` | The shared private root (e.g. `~/.private`). Computed, not created. |
|
|
181
|
+
| `create_shared_root_dir() -> Path` | Create the shared root if missing (mode `0700`); raise `PermissionError` if it exists with the wrong mode. |
|
|
182
|
+
| `get_root_dir() -> Path` | This manager's app-specific directory. Computed, not created. |
|
|
183
|
+
| `create_root_dir() -> Path` | Create the app-specific directory (and the shared root, if needed), fixing permissions at every level. |
|
|
184
|
+
| `get_private_dir(subdir) -> Path` | Resolve `subdir` under the app directory. Does not create anything. |
|
|
185
|
+
| `create_private_dir(subdir) -> Path` | Create `subdir` (and every intermediate component) under the app directory, mode `0700`. |
|
|
186
|
+
| `delete_private_dir(subdir) -> None` | Recursively delete `subdir`. No-op if it doesn't exist. Raises `ValueError` if `subdir` resolves to the shared root itself. |
|
|
187
|
+
| `verify_private_dir(subdir) -> Path` | Raise `NotADirectoryError`/`PermissionError` unless `subdir` (and everything above it, up to the shared root) exists with mode `0700`. |
|
|
188
|
+
| `get_private_file(filename, *, create_parent=False, subdir=".") -> Path` | Resolve the full path to a file. Verifies (or creates, if `create_parent=True`) its parent directory. The file itself is never created. |
|
|
189
|
+
| `open(filename, mode="r", *, subdir=".", create_parent=False, **kwargs) -> IO` | Like builtin `open()`, but resolved into the app directory. Parent directories are auto-created for write/append/exclusive-create modes (or when `create_parent=True`); files opened for writing get mode `0600`. |
|
|
190
|
+
|
|
191
|
+
`subdir="."` refers to the app directory itself. All `subdir`/`filename` parameters accept `str | Path`.
|
|
192
|
+
|
|
193
|
+
### Module-level functions
|
|
194
|
+
|
|
195
|
+
Thin wrappers around a cached `PrivateFilesManager` per `app_name`, for callers that don't want to hold
|
|
196
|
+
onto a manager instance themselves:
|
|
197
|
+
|
|
198
|
+
| Function | Equivalent to |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `get_shared_private_dir() -> Path` | `PrivateFilesManager().get_shared_root_dir()` |
|
|
201
|
+
| `create_shared_private_dir() -> Path` | `PrivateFilesManager().create_shared_root_dir()` |
|
|
202
|
+
| `get_private_files_manager(app_name=None) -> PrivateFilesManager` | Returns the cached manager for `app_name`. |
|
|
203
|
+
| `get_private_app_dir(app_name=None) -> Path` | `manager.get_root_dir()` |
|
|
204
|
+
| `create_private_app_dir(app_name=None) -> Path` | `manager.create_root_dir()` |
|
|
205
|
+
| `get_private_dir(subdir, app_name=None) -> Path` | `manager.get_private_dir(subdir)` |
|
|
206
|
+
| `create_private_dir(subdir, app_name=None) -> Path` | `manager.create_private_dir(subdir)` |
|
|
207
|
+
| `delete_private_dir(subdir_name, app_name) -> None` | `manager.delete_private_dir(subdir_name)` |
|
|
208
|
+
| `verify_private_dir(subdir_name, app_name=None) -> Path` | `manager.verify_private_dir(subdir_name)` |
|
|
209
|
+
| `get_private_app_file(filename, *, create_parent=False, subdir=".", app_name=None) -> Path` | `manager.get_private_file(...)` |
|
|
210
|
+
| `open_private_app_file(filename, mode="r", *, subdir=".", create_parent=False, app_name=None, **kwargs) -> IO` | `manager.open(...)` |
|
|
211
|
+
|
|
212
|
+
`get_private_files_manager(app_name)` is `@functools.cache`d, so repeated calls with the same `app_name`
|
|
213
|
+
(directly, or indirectly via any of the flat functions above) return the same manager instance and reuse
|
|
214
|
+
its already-resolved, already-verified paths. This means the *first* successful resolution of a given
|
|
215
|
+
`app_name`'s directory sticks for the lifetime of the process, even if the underlying shared root were to
|
|
216
|
+
change (e.g. `$HOME` changing at runtime) — construct a fresh `PrivateFilesManager` directly if you
|
|
217
|
+
need to bypass the cache.
|
|
218
|
+
|
|
219
|
+
## Examples
|
|
220
|
+
|
|
221
|
+
### Reading and writing binary data
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from private_files import open_private_app_file
|
|
225
|
+
|
|
226
|
+
with open_private_app_file("cache.bin", "wb", app_name="myapp") as f:
|
|
227
|
+
f.write(b"\x00\x01\x02")
|
|
228
|
+
|
|
229
|
+
with open_private_app_file("cache.bin", "rb", app_name="myapp") as f:
|
|
230
|
+
data = f.read()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Nested subdirectories
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from private_files import PrivateFilesManager
|
|
237
|
+
|
|
238
|
+
files = PrivateFilesManager(app_name="myapp")
|
|
239
|
+
files.create_private_dir("sessions/2024") # creates both levels, each mode 0700
|
|
240
|
+
path = files.get_private_dir("sessions/2024")
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Checking a directory without creating it
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from private_files import verify_private_dir
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
verify_private_dir("sessions", app_name="myapp")
|
|
250
|
+
except (NotADirectoryError, PermissionError) as e:
|
|
251
|
+
print(f"not ready: {e}")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Cleaning up
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from private_files import delete_private_dir
|
|
258
|
+
|
|
259
|
+
# Removes the directory and everything under it. No error if it doesn't exist.
|
|
260
|
+
delete_private_dir("sessions", app_name="myapp")
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Supported Python Versions
|
|
264
|
+
|
|
265
|
+
Python 3.10 through 3.14.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT. See [LICENSE](LICENSE).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
For development and release workflow documentation, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
private_files-1.0.0.dist-info/METADATA,sha256=15ubh_YiOclNIPZuHFOv--s3dcJF5lVm7WOR7twL0Z0,13820
|
|
2
|
+
private_files-1.0.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
|
|
3
|
+
private_files-1.0.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
private_files-1.0.0.dist-info/licenses/LICENSE,sha256=EiZGhGF2LQUXbByZTzYnYzbwZ-ATrF2MLPUxHKfPnHA,1075
|
|
5
|
+
private_files/__init__.py,sha256=hTYJoyI5AJP2nAAMWt3VqD8ZbUG9t6B1dbQTUSfXv0A,26703
|
|
6
|
+
private_files/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
private_files-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samuel J. McKelvie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|