oct
Loading...
Searching...
No Matches
fs_remove.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/tools/fs_remove.py
4
5"""
6Purpose
7-------
8One recursive-remove that survives a read-only directory, for every ``oct``
9command that deletes a generated tree.
10
11The defect this replaces: ``shutil.rmtree(path)`` raises
12``PermissionError: [WinError 5] Access is denied`` when a directory carries the
13Windows read-only attribute, because ``os.rmdir`` refuses it. Its
14``ignore_errors=True`` form is no better -- it swallows the refusal and leaves
15the tree standing while the caller reports success. On 2026-09-07 that let
16``oct clean`` count 947 surviving directories as removed; on 2026-09-25 the
17bare form ended ``oct docs --all`` in a traceback out of
18``run_sphinx_build``'s pre-build clean.
19
20The attribute is not exotic here (ENV-03): a Google Drive or OneDrive mirror
21sets it on every directory it syncs, so an entire checkout under a mirror root
22has it. Clearing the mirror is the real remedy and stays an operator action;
23this module is what makes the tools survive until then.
24
25Responsibilities
26----------------
27- :func:`remove_tree_force` -- remove a tree, clearing the read-only attribute
28 on whatever refuses, and report whether it is really gone.
29- :func:`remove_tree_or_warn` -- the same, plus the one standard warning, so
30 six call sites do not word the same failure six ways.
31- :data:`READ_ONLY_HINT` -- that wording, in one place.
32
33Diagnostics
34-----------
35Domain: OCT-CORE
36Levels:
37 L1 -- a tree that is still standing after the retry
38 L2 -- lifecycle (a tree removed)
39 L3 -- semantic details (the path, and the retry firing)
40 L4 -- deep tracing
41
42Contracts
43---------
44- The verdict is the filesystem's, never the call's: the return value is a
45 fresh existence check, so a swallowed error cannot read as success. This is
46 the property ``ignore_errors=True`` lacks and the reason it is not used here.
47- Failure is never raised. Every caller here is deleting generated output as
48 one step of a longer pipeline, and an unremovable cache directory must not
49 end the run with a traceback.
50- ``False`` does NOT mean nothing was deleted. A recursive delete works
51 bottom-up, so a refusal at the final ``rmdir`` has already taken the files
52 and leaves the directory shell behind. A caller that plans to rebuild into
53 the path should copy or build into what remains (``exist_ok`` /
54 ``dirs_exist_ok``) rather than treat the target as still intact.
55- The attribute is cleared only on a path that has already refused removal and
56 only to delete it. Nothing is made writable and left behind.
57- ``target`` that does not exist is success -- the postcondition ("it is not
58 there") already holds.
59- stdlib plus the ``_dbg`` shim only; imports nothing of ``oct``'s beyond
60 diagnostics, so any module may call it.
61
62Dependencies
63------------
64- oct.core.diagnostics (the ``_dbg`` shim)
65"""
66from __future__ import annotations
67
68import os
69import shutil
70import stat
71import sys
72from pathlib import Path
73
74from oct.core.diagnostics import _dbg
75
76#: Why a removal failed, in the one wording every caller uses. The attribute is
77#: the cause in every case seen so far; a genuine lock reads the same way to the
78#: operator, and the ``attrib`` command is harmless either way.
79READ_ONLY_HINT = (
80 "it or something inside it is read-only or locked. On Windows a Google "
81 "Drive / OneDrive mirror sets the read-only attribute on every synced "
82 "directory; clear it with: attrib -R /S /D <path>"
83)
84
85
86def remove_tree_force(target: Path) -> bool:
87 """Remove *target* recursively and report whether it is really gone.
88
89 Clears the read-only attribute on whatever refuses and retries that one
90 path once. Returns the result of an existence check made afterwards, so the
91 answer is what is on disk rather than what the call believed.
92 """
93 func = "remove_tree_force"
94 if not Path(target).exists():
95 _dbg("OCT-CORE", func, f"nothing to remove at {target}", 4)
96 return True
97
98 def _clear_and_retry(remover, path, _exc) -> None:
99 """Make one refusing path writable and try that one removal again."""
100 try:
101 os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
102 remover(path)
103 except OSError:
104 pass # the existence check below is the verdict
105
106 try:
107 if sys.version_info >= (3, 12):
108 shutil.rmtree(target, onexc=_clear_and_retry)
109 else: # pragma: no cover - the onerror signature carries exc_info
110 shutil.rmtree(target, onerror=_clear_and_retry)
111 except OSError as exc:
112 # SYSTEM_ERROR (S6/L1) - reported by the return value, never raised:
113 # the caller is mid-pipeline and owns what a failed clean means.
114 _dbg("OCT-CORE", func, f"SYSTEM_ERROR: rmtree({target}) raised: {exc}", 3)
115
116 gone = not Path(target).exists()
117 _dbg("OCT-CORE", func, f"{target}: {'removed' if gone else 'still present'}",
118 2 if gone else 1)
119 return gone
120
121
122def remove_tree_or_warn(target: Path, *, label: str = "") -> bool:
123 """Remove *target*; on failure print one standard warning and return False.
124
125 *label* names the thing in the operator's terms ("Sphinx doctree cache"),
126 so the warning says what was not cleaned as well as where it is.
127 """
128 func = "remove_tree_or_warn"
129 if remove_tree_force(target):
130 return True
131 what = f"{label} " if label else ""
132 print(f"Warning: could not remove {what}{target} - {READ_ONLY_HINT}",
133 file=sys.stderr)
134 _dbg("OCT-CORE", func, f"warned about surviving tree {target}", 3)
135 return False
bool remove_tree_or_warn(Path target, *, str label="")
Definition fs_remove.py:122
bool remove_tree_force(Path target)
Definition fs_remove.py:86