safer 4.12.3__tar.gz → 5.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: safer
3
- Version: 4.12.3
3
+ Version: 5.1.0
4
4
  Summary: 🧿 A safer writer for files and streams 🧿
5
5
  Home-page: https://github.com/rec/safer
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "safer"
3
- version = "4.12.3"
3
+ version = "5.1.0"
4
4
  description = "🧿 A safer writer for files and streams 🧿"
5
5
  authors = ["Tom Ritchford <tom@swirly.com>"]
6
6
  license = "MIT"
@@ -42,6 +42,8 @@ line-length = 88
42
42
  [tool.ruff.format]
43
43
  quote-style = "single"
44
44
 
45
+
46
+ [tool.mypy]
45
47
  [build-system]
46
48
  requires = ["poetry-core"]
47
49
  build-backend = "poetry.core.masonry.api"
@@ -1,5 +1,4 @@
1
- """
2
- # 🧿 `safer`: A safer writer 🧿
1
+ """# 🧿 `safer`: A safer writer 🧿
3
2
 
4
3
  Avoid partial writes or corruption!
5
4
 
@@ -42,6 +41,10 @@ https://pypi.org/project/atomicwrites/
42
41
  It also has a useful `dry_run` setting to let you test your code without
43
42
  actually overwriting the target file.
44
43
 
44
+ NOTE: Just like plain old `open`, if a file that is already opened for writing
45
+ is opened again before the first write has completed, the results are
46
+ unpredictable: so don't do it!
47
+
45
48
  * `safer.writer()` wraps an existing writer, socket or stream and writes a
46
49
  whole response or nothing
47
50
 
@@ -67,7 +70,6 @@ writes the data to a temporary file on disk, which is moved over using
67
70
  does not work on Windows. (In fact, it's unclear if any of this works on
68
71
  Windows, but that certainly won't. Windows developer solicted!)
69
72
 
70
-
71
73
  ### Example: `safer.writer()`
72
74
 
73
75
  `safer.writer()` wraps an existing stream - a writer, socket, or callback -
@@ -143,7 +145,10 @@ With `safer`
143
145
  for item in items:
144
146
  print(item)
145
147
  # Either the whole file is written, or nothing
148
+
146
149
  """
150
+ from __future__ import annotations
151
+
147
152
  import contextlib
148
153
  import functools
149
154
  import io
@@ -159,15 +164,15 @@ __all__ = 'writer', 'open', 'closer', 'dump', 'printer'
159
164
 
160
165
 
161
166
  def writer(
162
- stream: t.Union[t.Callable, None, t.IO, Path, str] = None,
163
- is_binary: t.Optional[bool] = None,
167
+ stream: t.Callable | None | t.IO | Path | str = None,
168
+ is_binary: bool | None = None,
164
169
  close_on_exit: bool = False,
165
170
  temp_file: bool = False,
166
171
  chunk_size: int = 0x100000,
167
172
  delete_failures: bool = True,
168
- dry_run: t.Union[bool, t.Callable] = False,
173
+ dry_run: bool | t.Callable = False,
169
174
  enabled: bool = True,
170
- ) -> t.Union[t.Callable, t.IO]:
175
+ ) -> t.Callable | t.IO:
171
176
  """
172
177
  Write safely to file streams, sockets and callables.
173
178
 
@@ -228,78 +233,84 @@ def writer(
228
233
  if not enabled:
229
234
  return stream
230
235
 
231
- write: t.Optional[t.Callable]
236
+ write: t.Callable | None
232
237
 
233
- if callable(dry_run):
234
- write, dry_run = dry_run, True
238
+ if close_on_exit and stream in (sys.stdout, sys.stderr):
239
+ raise ValueError('You cannot close stdout or stderr')
235
240
 
236
- elif dry_run:
237
- write = len
241
+ if dry_run:
242
+ close_on_exit = False
238
243
 
239
- elif close_on_exit and hasattr(stream, 'write'):
240
- if temp_file and BUG_MESSAGE:
241
- raise NotImplementedError(BUG_MESSAGE)
244
+ try:
245
+ if callable(dry_run):
246
+ write, dry_run = dry_run, True
242
247
 
243
- def write(v):
244
- with stream:
245
- stream.write(v)
248
+ elif dry_run:
249
+ write = len
246
250
 
247
- else:
248
- write = getattr(stream, 'write', None)
251
+ elif close_on_exit and hasattr(stream, 'write'):
252
+ if temp_file and BUG_MESSAGE:
253
+ raise NotImplementedError(BUG_MESSAGE)
249
254
 
250
- send = getattr(stream, 'send', None)
251
- mode = getattr(stream, 'mode', None)
255
+ def write(v):
256
+ with stream:
257
+ stream.write(v)
252
258
 
253
- if dry_run:
254
- close_on_exit = False
259
+ else:
260
+ write = getattr(stream, 'write', None)
255
261
 
256
- if close_on_exit and stream in (sys.stdout, sys.stderr):
257
- raise ValueError('You cannot close stdout or stderr')
262
+ send = getattr(stream, 'send', None)
263
+ mode = getattr(stream, 'mode', None)
258
264
 
259
- if write and mode:
260
- if not set('w+a').intersection(mode):
261
- raise ValueError('Stream mode "%s" is not a write mode' % mode)
265
+ if write and mode:
266
+ if not set('w+a').intersection(mode):
267
+ raise ValueError(f'Stream mode "{mode}" is not a write mode')
262
268
 
263
- binary_mode = 'b' in mode
264
- if is_binary is not None and is_binary is not binary_mode:
265
- raise ValueError('is_binary is inconsistent with the file stream')
269
+ binary_mode = 'b' in mode
270
+ if is_binary is not None and is_binary is not binary_mode:
271
+ raise ValueError('is_binary is inconsistent with the file stream')
266
272
 
267
- is_binary = binary_mode
273
+ is_binary = binary_mode
268
274
 
269
- elif dry_run:
270
- pass
275
+ elif dry_run:
276
+ pass
271
277
 
272
- elif send and hasattr(stream, 'recv'): # It looks like a socket:
273
- if not (is_binary is None or is_binary is True):
274
- raise ValueError('is_binary=False is inconsistent with a socket')
278
+ elif send and hasattr(stream, 'recv'): # It looks like a socket:
279
+ if not (is_binary is None or is_binary is True):
280
+ raise ValueError('is_binary=False is inconsistent with a socket')
275
281
 
276
- write = send
277
- is_binary = True
282
+ write = send
283
+ is_binary = True
278
284
 
279
- elif callable(stream):
280
- write = stream
285
+ elif callable(stream):
286
+ write = stream
281
287
 
282
- else:
283
- raise ValueError('Stream is not a file, a socket, or callable')
284
-
285
- closer: _StreamCloser
286
-
287
- if temp_file:
288
- closer = _FileStreamCloser(
289
- write,
290
- close_on_exit,
291
- is_binary,
292
- temp_file,
293
- chunk_size,
294
- delete_failures,
295
- )
296
- else:
297
- closer = _MemoryStreamCloser(write, close_on_exit, is_binary)
288
+ else:
289
+ raise ValueError('Stream is not a file, a socket, or callable')
290
+
291
+ closer: _StreamCloser
292
+
293
+ if temp_file:
294
+ closer = _FileStreamCloser(
295
+ write,
296
+ close_on_exit,
297
+ is_binary,
298
+ temp_file,
299
+ chunk_size,
300
+ delete_failures,
301
+ )
302
+ else:
303
+ closer = _MemoryStreamCloser(write, close_on_exit, is_binary)
304
+
305
+ if send is write:
306
+ closer.fp.send = write
298
307
 
299
- if send is write:
300
- closer.fp.send = write
308
+ return closer.fp
301
309
 
302
- return closer.fp
310
+ except Exception:
311
+ if close_on_exit:
312
+ getattr(stream, 'close', lambda: None)()
313
+ raise
303
314
 
304
315
 
305
316
  # There's an edge case in #23 I can't yet fix, so I fail
@@ -308,18 +319,18 @@ BUG_MESSAGE = 'Sorry, safer.writer fails if temp_file (#23)'
308
319
 
309
320
 
310
321
  def open(
311
- name: t.Union[Path, str],
322
+ name: Path | str,
312
323
  mode: str = 'r',
313
324
  buffering: int = -1,
314
- encoding: t.Optional[str] = None,
315
- errors: t.Optional[str] = None,
316
- newline: t.Optional[str] = None,
325
+ encoding: str | None = None,
326
+ errors: str | None = None,
327
+ newline: str | None = None,
317
328
  closefd: bool = True,
318
- opener: t.Optional[t.Callable] = None,
329
+ opener: t.Callable | None = None,
319
330
  make_parents: bool = False,
320
331
  delete_failures: bool = True,
321
332
  temp_file: bool = False,
322
- dry_run: t.Union[bool, t.Callable] = False,
333
+ dry_run: bool | t.Callable = False,
323
334
  enabled: bool = True,
324
335
  ) -> t.IO:
325
336
  """
@@ -382,7 +393,7 @@ def open(
382
393
  name = str(name)
383
394
 
384
395
  if not isinstance(name, str):
385
- raise TypeError('`name` must be string, not %s' % type(name).__name__)
396
+ raise TypeError(f'`name` must be string, not {type(name).__name__}')
386
397
 
387
398
  name = os.path.realpath(name)
388
399
  parent = os.path.dirname(os.path.abspath(name))
@@ -428,7 +439,7 @@ def open(
428
439
  raise ValueError("binary mode doesn't take an errors argument")
429
440
 
430
441
  if 'x' in mode and os.path.exists(name):
431
- raise FileExistsError("File exists: '%s'" % name)
442
+ raise FileExistsError(f"File exists: '{name}'")
432
443
 
433
444
  if buffering == -1:
434
445
  buffering = io.DEFAULT_BUFFER_SIZE
@@ -444,8 +455,8 @@ def open(
444
455
 
445
456
 
446
457
  def closer(
447
- stream: t.IO, is_binary: t.Optional[bool] = None, close_on_exit: bool = True, **kwds
448
- ) -> t.Union[t.Callable, t.IO]:
458
+ stream: t.IO, is_binary: bool | None = None, close_on_exit: bool = True, **kwds
459
+ ) -> t.Callable | t.IO:
449
460
  """
450
461
  Like `safer.writer()` but with `close_on_exit=True` by default
451
462
 
@@ -457,7 +468,7 @@ def closer(
457
468
 
458
469
  def dump(
459
470
  obj,
460
- stream: t.Union[t.Callable, None, t.IO, Path, str] = None,
471
+ stream: t.Callable | None | t.IO | Path | str = None,
461
472
  dump: t.Any = None,
462
473
  **kwargs,
463
474
  ) -> t.Any:
@@ -530,8 +541,8 @@ def _get_dumper(dump: t.Any) -> t.Callable:
530
541
 
531
542
  @contextlib.contextmanager
532
543
  def printer(
533
- name: t.Union[Path, str], mode: str = 'w', *args, **kwargs
534
- ) -> t.Generator[t.Callable, None, None]:
544
+ name: Path | str, mode: str = 'w', *args, **kwargs
545
+ ) -> t.Iterator[t.Callable]:
535
546
  """
536
547
  A context manager that yields a function that prints to the opened file,
537
548
  only writing to the original file at the exit of the context,
@@ -657,14 +668,16 @@ class _FileRenameCloser(_FileCloser):
657
668
  self.target_file = target_file
658
669
  self.dry_run = dry_run
659
670
  self.is_binary = is_binary
671
+ if temp_file is True:
672
+ parent, file = os.path.split(target_file)
673
+ temp_file = os.path.join(parent, f'.{file}.tmp-safer')
674
+
660
675
  super().__init__(temp_file, delete_failures, parent)
661
676
 
662
677
  def _success(self):
663
678
  if not self.dry_run:
664
679
  if os.path.exists(self.target_file):
665
680
  shutil.copymode(self.target_file, self.temp_file)
666
- else:
667
- os.chmod(self.temp_file, 0o100644)
668
681
  os.replace(self.temp_file, self.target_file)
669
682
 
670
683
  elif callable(self.dry_run):
File without changes
File without changes
File without changes