batchvault 0.1.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.
batchvault/signing.py ADDED
@@ -0,0 +1,152 @@
1
+ """Ed25519 signatures for manifests (needs ``batchvault[crypto]``).
2
+
3
+ The signature is stored next to the manifest as ``<manifest>.sig`` and covers
4
+ the exact bytes of the manifest file. Anyone with the public key can verify it;
5
+ only the holder of the private key can create it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from batchvault.crypto import require_cryptography
15
+ from batchvault.errors import InputError, SignatureError
16
+
17
+ SIGNATURE_SUFFIX = ".sig"
18
+
19
+
20
+ def signature_path(manifest_path: Path) -> Path:
21
+ return manifest_path.with_name(manifest_path.name + SIGNATURE_SUFFIX)
22
+
23
+
24
+ def _write_private(path: Path, data: bytes) -> None:
25
+ # Create the file readable by its owner only, before any key is written.
26
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
27
+ with os.fdopen(fd, "wb") as handle:
28
+ handle.write(data)
29
+
30
+
31
+ def generate_keypair(
32
+ private_path: Path,
33
+ public_path: Path,
34
+ *,
35
+ password: str | None = None,
36
+ overwrite: bool = False,
37
+ ) -> None:
38
+ """Create an Ed25519 key pair as PEM files.
39
+
40
+ The private key is encrypted with ``password`` when one is given.
41
+ """
42
+ require_cryptography()
43
+ from cryptography.hazmat.primitives import serialization
44
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
45
+
46
+ for path in (private_path, public_path):
47
+ if path.exists() and not overwrite:
48
+ raise InputError(f"{path} already exists. Use --force to replace it.")
49
+
50
+ key = Ed25519PrivateKey.generate()
51
+ encryption = (
52
+ serialization.BestAvailableEncryption(password.encode("utf-8"))
53
+ if password
54
+ else serialization.NoEncryption()
55
+ )
56
+ _write_private(
57
+ private_path,
58
+ key.private_bytes(
59
+ serialization.Encoding.PEM,
60
+ serialization.PrivateFormat.PKCS8,
61
+ encryption,
62
+ ),
63
+ )
64
+ public_path.write_bytes(
65
+ key.public_key().public_bytes(
66
+ serialization.Encoding.PEM,
67
+ serialization.PublicFormat.SubjectPublicKeyInfo,
68
+ )
69
+ )
70
+
71
+
72
+ def load_private_key(path: Path, password: str | None = None):
73
+ require_cryptography()
74
+ from cryptography.hazmat.primitives import serialization
75
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
76
+
77
+ try:
78
+ key = serialization.load_pem_private_key(
79
+ path.read_bytes(), password.encode("utf-8") if password else None
80
+ )
81
+ except OSError as exc:
82
+ raise SignatureError(f"Cannot read private key {path}: {exc}") from exc
83
+ except (ValueError, TypeError) as exc:
84
+ raise SignatureError(
85
+ f"Cannot load private key {path} (wrong password or not a key): {exc}"
86
+ ) from exc
87
+
88
+ if not isinstance(key, Ed25519PrivateKey):
89
+ raise SignatureError(f"{path} is not an Ed25519 private key.")
90
+ return key
91
+
92
+
93
+ def load_public_key(path: Path):
94
+ require_cryptography()
95
+ from cryptography.hazmat.primitives import serialization
96
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
97
+
98
+ try:
99
+ key = serialization.load_pem_public_key(path.read_bytes())
100
+ except OSError as exc:
101
+ raise SignatureError(f"Cannot read public key {path}: {exc}") from exc
102
+ except (ValueError, TypeError) as exc:
103
+ raise SignatureError(f"Cannot load public key {path}: {exc}") from exc
104
+
105
+ if not isinstance(key, Ed25519PublicKey):
106
+ raise SignatureError(f"{path} is not an Ed25519 public key.")
107
+ return key
108
+
109
+
110
+ def sign_manifest(manifest_path: Path, private_key) -> Path:
111
+ """Sign the manifest with a loaded private key; return the signature file."""
112
+ signature = private_key.sign(manifest_path.read_bytes())
113
+ target = signature_path(manifest_path)
114
+ target.write_text(base64.b64encode(signature).decode("ascii") + "\n")
115
+ return target
116
+
117
+
118
+ def verify_manifest_bytes(
119
+ data: bytes, manifest_path: Path, public_key_path: Path
120
+ ) -> None:
121
+ """Raise SignatureError unless ``data`` carries a valid signature.
122
+
123
+ ``data`` is the manifest content, already read from ``manifest_path``; the
124
+ signature file sits next to it. Checking the bytes that are then parsed
125
+ leaves no gap in which the file could be swapped.
126
+ """
127
+ from cryptography.exceptions import InvalidSignature
128
+
129
+ public_key = load_public_key(public_key_path)
130
+ sig_file = signature_path(manifest_path)
131
+ if not sig_file.is_file():
132
+ raise SignatureError(
133
+ f"{manifest_path.name} is not signed (no {sig_file.name})."
134
+ )
135
+
136
+ try:
137
+ signature = base64.b64decode(sig_file.read_text().strip(), validate=True)
138
+ public_key.verify(signature, data)
139
+ except (ValueError, InvalidSignature) as exc:
140
+ raise SignatureError(
141
+ f"Signature check failed for {manifest_path.name}: the manifest was "
142
+ "changed or signed with a different key."
143
+ ) from exc
144
+
145
+
146
+ def verify_manifest_signature(manifest_path: Path, public_key_path: Path) -> None:
147
+ """Raise SignatureError unless the manifest has a valid signature."""
148
+ try:
149
+ data = manifest_path.read_bytes()
150
+ except OSError as exc:
151
+ raise SignatureError(f"Cannot read {manifest_path}: {exc}") from exc
152
+ verify_manifest_bytes(data, manifest_path, public_key_path)
batchvault/ui.py ADDED
@@ -0,0 +1,298 @@
1
+ """Rich terminal output. Only the command-line interface imports this module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.markup import escape
10
+ from rich.panel import Panel
11
+ from rich.progress import (
12
+ BarColumn,
13
+ Progress,
14
+ ProgressColumn,
15
+ SpinnerColumn,
16
+ TaskProgressColumn,
17
+ TextColumn,
18
+ TimeElapsedColumn,
19
+ )
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from batchvault.doctor import Check
24
+ from batchvault.eta import EtaEstimator, format_duration
25
+ from batchvault.models import InspectEntry, ProgressEvent, RunResult, Status
26
+ from batchvault.options import format_size
27
+
28
+ STATUS_STYLE = {
29
+ Status.SUCCESS: ("OK", "green"),
30
+ Status.SKIPPED: ("SKIPPED", "yellow"),
31
+ Status.FAILED: ("FAILED", "bold red"),
32
+ Status.PARTIAL: ("PARTIAL", "yellow"),
33
+ }
34
+
35
+
36
+ class EtaColumn(ProgressColumn):
37
+ """Shows the run's own ETA for tasks created with an ``estimator`` field."""
38
+
39
+ def render(self, task):
40
+ estimator = task.fields.get("estimator")
41
+ if estimator is None:
42
+ return Text("")
43
+
44
+ remaining = estimator.estimate(task.completed, task.total or 0)
45
+ if remaining is None:
46
+ return Text("ETA estimating…", style="dim")
47
+ return Text(f"ETA {format_duration(remaining)}", style="progress.remaining")
48
+
49
+
50
+ def status_text(status: Status) -> Text:
51
+ label, style = STATUS_STYLE[status]
52
+ return Text(label, style=style)
53
+
54
+
55
+ class RunProgress:
56
+ """Progress callback that draws an overall bar and one line per active file.
57
+
58
+ Use as a context manager and pass the instance as ``progress=``. With
59
+ ``enabled=False`` it does nothing.
60
+ """
61
+
62
+ def __init__(self, console: Console, label: str, enabled: bool = True):
63
+ self._enabled = enabled
64
+ self._label = label
65
+ self._lock = threading.Lock()
66
+ self._eta = EtaEstimator()
67
+ self._by_bytes = False
68
+ self._overall = None
69
+ self._tasks: dict[Path, int] = {}
70
+ self._progress = Progress(
71
+ SpinnerColumn(),
72
+ TextColumn("[bold cyan]{task.description}"),
73
+ BarColumn(),
74
+ TaskProgressColumn(),
75
+ TimeElapsedColumn(),
76
+ EtaColumn(),
77
+ console=console,
78
+ transient=True,
79
+ disable=not enabled,
80
+ )
81
+
82
+ def __enter__(self):
83
+ if self._enabled:
84
+ self._progress.start()
85
+ return self
86
+
87
+ def __exit__(self, *exc_info):
88
+ self._progress.stop()
89
+
90
+ def pause(self) -> None:
91
+ """Stop drawing, e.g. before asking the user a question."""
92
+ self._progress.stop()
93
+
94
+ def __call__(self, event: ProgressEvent) -> None:
95
+ if not self._enabled:
96
+ return
97
+
98
+ with self._lock:
99
+ if event.kind == "plan":
100
+ self._by_bytes = event.size > 0
101
+ total = event.size if self._by_bytes else event.count
102
+ self._overall = self._progress.add_task(
103
+ self._label, total=total, estimator=self._eta
104
+ )
105
+ self._eta.start()
106
+ elif event.kind == "start":
107
+ task = self._progress.add_task(escape(event.source.name), total=None)
108
+ self._tasks[event.source] = task
109
+ elif event.kind == "finish":
110
+ self._finish(event)
111
+
112
+ def _finish(self, event: ProgressEvent) -> None:
113
+ task = self._tasks.pop(event.source, None)
114
+ if task is not None:
115
+ self._progress.remove_task(task)
116
+ if self._overall is not None:
117
+ self._progress.advance(
118
+ self._overall, event.size if self._by_bytes else 1
119
+ )
120
+
121
+ label, style = STATUS_STYLE.get(event.status, ("", ""))
122
+ self._progress.console.print(
123
+ Text.assemble((f"{label:<8}", style), event.source.name)
124
+ )
125
+
126
+
127
+ def print_run_info(console: Console, title: str, rows: list[tuple[str, str]]) -> None:
128
+ info = Table.grid(padding=(0, 2))
129
+ info.add_column(style="bold cyan")
130
+ info.add_column()
131
+ for key, value in rows:
132
+ info.add_row(key, Text(value))
133
+ console.print(Panel(info, title=title, border_style="cyan"))
134
+
135
+
136
+ # Terms used on screen: a SOURCE is an original file; an ARCHIVE is the set of
137
+ # volumes made from it. Each table names its columns accordingly.
138
+ SOURCE_STYLE = "cyan"
139
+ ARCHIVE_STYLE = "magenta"
140
+
141
+ SUMMARY_TITLES = {
142
+ "pack": "Pack: source files -> archives",
143
+ "unpack": "Unpack: archives -> restored files",
144
+ "verify": "Verify: archives",
145
+ }
146
+
147
+
148
+ def _shown(folder: str, name: str) -> str:
149
+ return f"{folder}/{name}" if folder else name
150
+
151
+
152
+ def _detail_text(entry) -> Text:
153
+ style = "dim" if entry.status == Status.SUCCESS else ""
154
+ text = Text(entry.message, style=style)
155
+ for warning in entry.warnings:
156
+ text.append(f"\n{warning}", style="yellow")
157
+ return text
158
+
159
+
160
+ def print_summary(
161
+ console: Console, result: RunResult, title: str | None = None, kind: str = "pack"
162
+ ) -> None:
163
+ """Print one row per file. ``kind`` is pack, unpack or verify."""
164
+ table = Table(title=title or SUMMARY_TITLES.get(kind, ""), show_lines=False)
165
+
166
+ # The same columns as `inspect`: the original file, its archive, the outcome
167
+ # and an explanation.
168
+ table.add_column("Source", style=SOURCE_STYLE, overflow="fold")
169
+ table.add_column("Archive", style=ARCHIVE_STYLE, overflow="fold")
170
+ table.add_column("Status")
171
+ table.add_column("Details", overflow="fold")
172
+
173
+ for entry in result.entries:
174
+ # pack: the source is the original file and the archive its first volume.
175
+ # unpack and verify: the "source" is the archive's first volume.
176
+ if kind == "pack":
177
+ archive = entry.archive.name if entry.archive else "-"
178
+ original = entry.name or entry.source.name
179
+ else:
180
+ archive = entry.source.name
181
+ original = entry.name or "-"
182
+ table.add_row(
183
+ Text(_shown(entry.folder, original)),
184
+ Text(archive),
185
+ status_text(entry.status),
186
+ _detail_text(entry),
187
+ )
188
+ console.print(table)
189
+
190
+ for warning in result.warnings:
191
+ console.print(Text(f"Warning: {warning}", style="yellow"))
192
+
193
+ counts = ", ".join(
194
+ f"{result.count(status)} {label.lower()}"
195
+ for status, (label, _) in STATUS_STYLE.items()
196
+ if status != Status.PARTIAL and result.count(status)
197
+ )
198
+ console.print(f"[bold]Summary:[/bold] {escape(counts or 'nothing to do')}")
199
+ if result.manifest and any(e.status == Status.SUCCESS for e in result.entries):
200
+ console.print(f"Manifest: {escape(str(result.manifest))}")
201
+
202
+
203
+ def _archive_status(entry: InspectEntry) -> Text:
204
+ if entry.complete is True:
205
+ return Text("OK", style="green")
206
+ if entry.complete is False:
207
+ return Text("MISSING", style="bold red")
208
+ return Text("-", style="dim") # no manifest, so completeness is unknown
209
+
210
+
211
+ def _inspect_details(entry: InspectEntry) -> Text:
212
+ notes = []
213
+ if entry.encrypted:
214
+ notes.append(("encrypted", "yellow"))
215
+ if entry.complete is False:
216
+ notes.append(("some volumes are missing", "bold red"))
217
+ if entry.complete is None:
218
+ notes.append(("no manifest: sizes and checks unknown", "dim"))
219
+
220
+ text = Text()
221
+ for index, (note, style) in enumerate(notes):
222
+ if index:
223
+ text.append(", ")
224
+ text.append(note, style=style)
225
+ return text
226
+
227
+
228
+ def print_inspect(
229
+ console: Console, entries: list[InspectEntry], source: str | None = None
230
+ ) -> None:
231
+ """Describe archives with the same layout and colours as the result tables."""
232
+ original = sum(e.size or 0 for e in entries)
233
+ stored = sum(e.stored_size or 0 for e in entries)
234
+ missing = sum(1 for e in entries if e.complete is False)
235
+ encrypted = sum(1 for e in entries if e.encrypted)
236
+
237
+ rows = []
238
+ if source:
239
+ rows.append(("Archives", source))
240
+ rows.append(("Files", str(len(entries))))
241
+ if original:
242
+ sizes = f"{format_size(original)} -> {format_size(stored)}"
243
+ rows.append(("Size", f"{sizes} ({stored / original:.0%})"))
244
+ else:
245
+ rows.append(("Stored size", format_size(stored)))
246
+ if encrypted:
247
+ rows.append(("Encrypted", f"{encrypted} of {len(entries)}"))
248
+ print_run_info(console, "BatchVault inspect", rows)
249
+
250
+ table = Table(title="Inspect: archives", show_lines=False)
251
+ table.add_column("Source", style=SOURCE_STYLE, overflow="fold")
252
+ table.add_column("Archive", style=ARCHIVE_STYLE, overflow="fold")
253
+ table.add_column("Status")
254
+ table.add_column("Engine")
255
+ table.add_column("Format")
256
+ table.add_column("Level", justify="right")
257
+ table.add_column("Original", justify="right")
258
+ table.add_column("Stored", justify="right")
259
+ table.add_column("Ratio", justify="right")
260
+ table.add_column("Volumes", justify="right")
261
+ table.add_column("Details", overflow="fold")
262
+
263
+ for entry in entries:
264
+ ratio = f"{entry.ratio:.0%}" if entry.ratio is not None else "-"
265
+ table.add_row(
266
+ Text(_shown(entry.folder, entry.name)),
267
+ Text(entry.path.name),
268
+ _archive_status(entry),
269
+ entry.engine,
270
+ entry.format or "-",
271
+ "-" if entry.level is None else str(entry.level),
272
+ format_size(entry.size),
273
+ format_size(entry.stored_size),
274
+ ratio,
275
+ str(entry.volumes),
276
+ _inspect_details(entry),
277
+ )
278
+ console.print(table)
279
+
280
+ summary = f"{len(entries)} archive{'' if len(entries) == 1 else 's'}"
281
+ if missing:
282
+ summary += f", {missing} with missing volumes"
283
+ console.print(f"[bold]Summary:[/bold] {escape(summary)}")
284
+
285
+
286
+ def print_checks(console: Console, checks: list[Check]) -> None:
287
+ table = Table(title="BatchVault doctor")
288
+ table.add_column("Check")
289
+ table.add_column("Result")
290
+ table.add_column("Details", overflow="fold")
291
+
292
+ for check in checks:
293
+ if check.ok:
294
+ result = Text("OK", style="green")
295
+ else:
296
+ result = Text("FAILED" if check.required else "MISSING", style="red")
297
+ table.add_row(check.name, result, Text(check.detail))
298
+ console.print(table)
batchvault/volumes.py ADDED
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import re
6
+ import shutil
7
+ import time
8
+ import unicodedata
9
+ from pathlib import Path
10
+
11
+ from batchvault.errors import VolumeError
12
+ from batchvault.security import restrict_permissions
13
+
14
+ VOLUME_RE = re.compile(r"^(?P<base>.+\.(?:7z|bv))\.(?P<number>\d{3,})$", re.IGNORECASE)
15
+
16
+
17
+ def split_volume_name(name: str) -> tuple[str, int] | None:
18
+ """Return ``(base, number)`` for ``intro.mp4.7z.003``, or None if not a volume."""
19
+ match = VOLUME_RE.match(name)
20
+ if not match:
21
+ return None
22
+ return match.group("base"), int(match.group("number"))
23
+
24
+
25
+ def _same_name(a: str, b: str) -> bool:
26
+ """Compare names ignoring case and Unicode form (macOS decomposes accents)."""
27
+ return (
28
+ unicodedata.normalize("NFC", a).casefold()
29
+ == unicodedata.normalize("NFC", b).casefold()
30
+ )
31
+
32
+
33
+ def existing_volumes(directory: Path, base: str) -> list[Path]:
34
+ """Volumes of ``base`` already present in ``directory``, in volume order."""
35
+ if not directory.is_dir():
36
+ return []
37
+
38
+ found = []
39
+ for path in directory.iterdir():
40
+ parsed = split_volume_name(path.name)
41
+ if path.is_file() and parsed and _same_name(parsed[0], base):
42
+ found.append((parsed[1], path))
43
+ return [path for _, path in sorted(found)]
44
+
45
+
46
+ def find_volumes(path: Path) -> list[Path]:
47
+ """Find every volume of the set that ``path`` belongs to.
48
+
49
+ Any volume of the set may be given. Raises VolumeError if the path is not a
50
+ volume or if a volume inside the numbered range is missing. A missing
51
+ *trailing* volume cannot be detected without a manifest.
52
+ """
53
+ parsed = split_volume_name(path.name)
54
+ if parsed is None:
55
+ raise VolumeError(f"Not an archive volume: {path}")
56
+
57
+ volumes = existing_volumes(path.parent, parsed[0])
58
+ numbers = [split_volume_name(volume.name)[1] for volume in volumes]
59
+
60
+ missing = sorted(set(range(1, max(numbers) + 1)) - set(numbers))
61
+ if missing:
62
+ listed = ", ".join(f"{number:03d}" for number in missing)
63
+ raise VolumeError(
64
+ f"Missing volume(s) {listed} for {parsed[0]} in {path.parent}"
65
+ )
66
+
67
+ return volumes
68
+
69
+
70
+ STAGING_PREFIX = ".batchvault-"
71
+
72
+
73
+ def cleanup_staging(root: Path, max_age: float = 86400.0) -> int:
74
+ """Remove staging folders left behind by a killed run; return how many.
75
+
76
+ Looks through the whole tree under ``root``. Only folders untouched for
77
+ ``max_age`` seconds are removed, so a run in progress is never disturbed.
78
+ """
79
+ if not root.is_dir():
80
+ return 0
81
+
82
+ removed = 0
83
+ now = time.time()
84
+ for current, dirnames, _ in os.walk(root): # does not follow links
85
+ for name in list(dirnames):
86
+ if not name.startswith(STAGING_PREFIX):
87
+ continue
88
+ dirnames.remove(name) # never look inside a staging folder
89
+ item = Path(current) / name
90
+ try:
91
+ if not item.is_symlink() and now - item.stat().st_mtime > max_age:
92
+ shutil.rmtree(item)
93
+ removed += 1
94
+ except OSError:
95
+ continue
96
+ return removed
97
+
98
+
99
+ def install_volumes(built: list[Path], dest_dir: Path, existing: list[Path]):
100
+ """Move a finished volume set into ``dest_dir``, replacing ``existing``.
101
+
102
+ Old volumes are removed only here, after the new set was built completely.
103
+ """
104
+ for old in existing:
105
+ old.unlink()
106
+
107
+ installed = []
108
+ for volume in built:
109
+ # Archives may hold private data; do not let umask make them readable by
110
+ # other users (no effect on Windows, where folder permissions apply).
111
+ restrict_permissions(volume)
112
+ target = dest_dir / volume.name
113
+ os.replace(volume, target)
114
+ installed.append(target)
115
+ return installed
116
+
117
+
118
+ class VolumeWriter:
119
+ """Write-only file object that rolls over to a new volume every N bytes."""
120
+
121
+ def __init__(self, directory: Path, base: str, volume_size: int):
122
+ self._directory = directory
123
+ self._base = base
124
+ self._volume_size = volume_size
125
+ self._file = None
126
+ self._room = 0
127
+ self.volumes: list[Path] = []
128
+
129
+ def write(self, data) -> int:
130
+ view = memoryview(data)
131
+ total = len(view)
132
+
133
+ while len(view):
134
+ if self._room == 0:
135
+ self._next_volume()
136
+ chunk = view[: self._room]
137
+ self._file.write(chunk)
138
+ self._room -= len(chunk)
139
+ view = view[len(chunk) :]
140
+
141
+ return total
142
+
143
+ def _next_volume(self) -> None:
144
+ self._close_current()
145
+ number = len(self.volumes) + 1
146
+ path = self._directory / f"{self._base}.{number:03d}"
147
+ self._file = path.open("wb")
148
+ self.volumes.append(path)
149
+ self._room = self._volume_size
150
+
151
+ def _close_current(self) -> None:
152
+ if self._file is not None:
153
+ self._file.close()
154
+ self._file = None
155
+
156
+ def close(self) -> None:
157
+ self._close_current()
158
+
159
+ def __enter__(self):
160
+ return self
161
+
162
+ def __exit__(self, *exc_info):
163
+ self.close()
164
+
165
+
166
+ class VolumeReader(io.RawIOBase):
167
+ """Read-only file object that reads a volume set as one continuous stream."""
168
+
169
+ def __init__(self, paths):
170
+ super().__init__()
171
+ self._paths = list(paths)
172
+ self._index = 0
173
+ self._file = None
174
+
175
+ def readable(self) -> bool:
176
+ return True
177
+
178
+ def readinto(self, buffer) -> int:
179
+ view = memoryview(buffer).cast("B")
180
+ if not len(view):
181
+ return 0
182
+
183
+ while True:
184
+ if self._file is None:
185
+ if self._index >= len(self._paths):
186
+ return 0
187
+ self._file = self._paths[self._index].open("rb")
188
+ self._index += 1
189
+
190
+ count = self._file.readinto(view)
191
+ if count:
192
+ return count
193
+
194
+ self._file.close()
195
+ self._file = None
196
+
197
+ def close(self) -> None:
198
+ if self._file is not None:
199
+ self._file.close()
200
+ self._file = None
201
+ super().close()