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.
@@ -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
+ [![CI](https://img.shields.io/badge/CI-passing-brightgreen.svg)](https://github.com/mckelvie-org/private-files/actions/workflows/ci.yml)
54
+ [![PyPI version](https://img.shields.io/badge/pypi-v1.0.0-blue.svg)](https://pypi.org/project/private-files/1.0.0/)
55
+ [![Python versions](https://img.shields.io/badge/python-3.10%20|%203.11%20|%203.12%20|%203.13%20|%203.14-blue.svg)](https://pypi.org/project/private-files/)
56
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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 &mdash; API tokens, session cookies,
61
+ credentials, profile data &mdash; 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 &mdash; 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()` &mdash; including `@overload`-based mode-based return-type inference (`TextIO` vs
86
+ `BinaryIO`) &mdash; 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 &mdash; 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 &mdash; 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) &mdash; 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.9)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -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.