check-empty 1.2.3__tar.gz → 1.3.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.
- {check_empty-1.2.3 → check_empty-1.3.0}/PKG-INFO +30 -12
- {check_empty-1.2.3 → check_empty-1.3.0}/README.md +16 -10
- check_empty-1.3.0/check_empty/__init__.py +234 -0
- {check_empty-1.2.3 → check_empty-1.3.0}/check_empty/__main__.py +1 -1
- check_empty-1.3.0/check_empty/archives.py +45 -0
- check_empty-1.3.0/check_empty/constants.py +44 -0
- check_empty-1.3.0/check_empty/reporter.py +144 -0
- check_empty-1.3.0/check_empty/util.py +86 -0
- {check_empty-1.2.3 → check_empty-1.3.0}/pyproject.toml +50 -9
- {check_empty-1.2.3 → check_empty-1.3.0}/pyproject.toml.orig +36 -7
- check_empty-1.2.3/check_empty/__init__.py +0 -276
- {check_empty-1.2.3 → check_empty-1.3.0}/AUTHORS.md +0 -0
- {check_empty-1.2.3 → check_empty-1.3.0}/LICENSE +0 -0
- {check_empty-1.2.3 → check_empty-1.3.0}/check_empty/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: check-empty
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Command-line utility, as well as a hook usable by the Python pre-commit framework, and a GitHub Action, to ensure that selected files are empty.
|
|
5
5
|
Keywords: check,command-line,empty,files,github-action,helper,hook,lightweight,pre-commit,prek,tool,utility
|
|
6
6
|
Author: Jonathan Dung
|
|
@@ -28,13 +28,27 @@ Classifier: Programming Language :: Python :: 3.16
|
|
|
28
28
|
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
29
29
|
Classifier: Topic :: Utilities
|
|
30
30
|
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Dist: py7zr==1.1.3 ; python_full_version >= '3.10' and extra == '7z'
|
|
32
|
+
Requires-Dist: py7zr==1.0.0 ; python_full_version == '3.9.*' and extra == '7z'
|
|
33
|
+
Requires-Dist: py7zr==0.22.0 ; python_full_version == '3.8.*' and extra == '7z'
|
|
34
|
+
Requires-Dist: py7zr==0.20.6 ; python_full_version == '3.7.*' and extra == '7z'
|
|
35
|
+
Requires-Dist: check-empty[7z,lzh,rar]==1.3.0 ; extra == 'all'
|
|
36
|
+
Requires-Dist: lhafile==0.3.1 ; extra == 'lzh'
|
|
37
|
+
Requires-Dist: rarfile==4.5 ; python_full_version >= '3.10' and extra == 'rar'
|
|
38
|
+
Requires-Dist: rarfile==4.2 ; python_full_version < '3.10' and extra == 'rar'
|
|
31
39
|
Maintainer: Jonathan Dung
|
|
32
40
|
Maintainer-email: Jonathan Dung <jonathandung@yahoo.com>
|
|
33
|
-
Requires-Python: >=3.
|
|
41
|
+
Requires-Python: >=3.7
|
|
42
|
+
Provides-Extra: 7z
|
|
43
|
+
Provides-Extra: all
|
|
44
|
+
Provides-Extra: lzh
|
|
45
|
+
Provides-Extra: rar
|
|
34
46
|
Description-Content-Type: text/markdown
|
|
35
47
|
|
|
36
48
|
# [check-empty](https://pypi.org/p/check-empty)
|
|
37
49
|
|
|
50
|
+
[](https://github.com/TheBSD/StandWithPalestine/blob/main/docs/README.md)
|
|
51
|
+
|
|
38
52
|
A simple, dependency-free [pre-commit](https://pre-commit.com) /
|
|
39
53
|
[prek](https://prek.j178.dev) hook, CLI, library and
|
|
40
54
|
[GitHub Action](https://github.com/marketplace/actions/check-empty-files) conglomerate
|
|
@@ -46,8 +60,11 @@ specified.
|
|
|
46
60
|
|
|
47
61
|
## Prerequisites
|
|
48
62
|
|
|
49
|
-
Supports CPython 3.
|
|
50
|
-
Python 3.
|
|
63
|
+
Supports CPython 3.7+, PyPy 7.3.4+, GraalPy 19.0+ out-of-the-box, and most likely every
|
|
64
|
+
Python 3.7 runtime you can think of. This is the only requirement to use this tool.
|
|
65
|
+
|
|
66
|
+
We cannot support Python 3.6 because of a vulnerability in `py7zr` 0.19.2, which is the
|
|
67
|
+
latest version of `py7zr` that supports Python 3.6.
|
|
51
68
|
|
|
52
69
|
## Quickstart
|
|
53
70
|
|
|
@@ -115,7 +132,7 @@ As a pre-commit hook:
|
|
|
115
132
|
# .pre-commit-config.yaml
|
|
116
133
|
repos:
|
|
117
134
|
- repo: https://github.com/jonathandung/check-empty
|
|
118
|
-
rev: v1.
|
|
135
|
+
rev: v1.3.0 # repository version
|
|
119
136
|
hooks:
|
|
120
137
|
- id: check-empty # the hook
|
|
121
138
|
args: # example list of arguments
|
|
@@ -131,7 +148,7 @@ equivalent in `prek.toml` format:
|
|
|
131
148
|
```toml
|
|
132
149
|
[[repos]]
|
|
133
150
|
repo = "https://github.com/jonathandung/check-empty"
|
|
134
|
-
rev = "v1.
|
|
151
|
+
rev = "v1.3.0"
|
|
135
152
|
|
|
136
153
|
[[repos.hooks]]
|
|
137
154
|
id = "check-empty"
|
|
@@ -155,7 +172,7 @@ or (TOML 1.1+):
|
|
|
155
172
|
# using multiline inline tables
|
|
156
173
|
[[repos]]
|
|
157
174
|
repo = "https://github.com/jonathandung/check-empty"
|
|
158
|
-
rev = "v1.
|
|
175
|
+
rev = "v1.3.0"
|
|
159
176
|
hooks = [{
|
|
160
177
|
id = "check-empty",
|
|
161
178
|
args = ["-Q"],
|
|
@@ -175,12 +192,12 @@ As a GitHub Actions workflow step:
|
|
|
175
192
|
|
|
176
193
|
```yaml
|
|
177
194
|
steps:
|
|
178
|
-
- uses: jonathandung/check-empty@v1.
|
|
195
|
+
- uses: jonathandung/check-empty@v1.3.0 # the latest version on the GitHub Actions
|
|
179
196
|
# marketplace; this step will fail and subsequent jobs will not run if any file is
|
|
180
197
|
# not empty
|
|
181
198
|
with:
|
|
182
199
|
python-version: '3.14' # run the script on the latest stable Python version
|
|
183
|
-
# Python down to 3.
|
|
200
|
+
# Python down to 3.7 is supported but not recommended due to end-of-life
|
|
184
201
|
verbosity: 1
|
|
185
202
|
filenames: |
|
|
186
203
|
src/mylib/py.typed
|
|
@@ -227,9 +244,10 @@ to read the
|
|
|
227
244
|
and the
|
|
228
245
|
[contributing guide](https://github.com/jonathandung/.github/blob/main/CONTRIBUTING.md).
|
|
229
246
|
|
|
230
|
-
To build the docs locally (needs Python 3.12+), install with the
|
|
231
|
-
[group](https://packaging.python.org/en/latest/specifications/dependency-groups)
|
|
232
|
-
|
|
247
|
+
To build the docs locally (needs Python 3.12+ because of Sphinx), install with the
|
|
248
|
+
`docs` [group](https://packaging.python.org/en/latest/specifications/dependency-groups)
|
|
249
|
+
using a package manager that supports it (e.g. pip 25.1+ or uv 0.4.27+), preferably
|
|
250
|
+
into a virtual environment.
|
|
233
251
|
|
|
234
252
|
Tests are run with:
|
|
235
253
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# [check-empty](https://pypi.org/p/check-empty)
|
|
2
2
|
|
|
3
|
+
[](https://github.com/TheBSD/StandWithPalestine/blob/main/docs/README.md)
|
|
4
|
+
|
|
3
5
|
A simple, dependency-free [pre-commit](https://pre-commit.com) /
|
|
4
6
|
[prek](https://prek.j178.dev) hook, CLI, library and
|
|
5
7
|
[GitHub Action](https://github.com/marketplace/actions/check-empty-files) conglomerate
|
|
@@ -11,8 +13,11 @@ specified.
|
|
|
11
13
|
|
|
12
14
|
## Prerequisites
|
|
13
15
|
|
|
14
|
-
Supports CPython 3.
|
|
15
|
-
Python 3.
|
|
16
|
+
Supports CPython 3.7+, PyPy 7.3.4+, GraalPy 19.0+ out-of-the-box, and most likely every
|
|
17
|
+
Python 3.7 runtime you can think of. This is the only requirement to use this tool.
|
|
18
|
+
|
|
19
|
+
We cannot support Python 3.6 because of a vulnerability in `py7zr` 0.19.2, which is the
|
|
20
|
+
latest version of `py7zr` that supports Python 3.6.
|
|
16
21
|
|
|
17
22
|
## Quickstart
|
|
18
23
|
|
|
@@ -80,7 +85,7 @@ As a pre-commit hook:
|
|
|
80
85
|
# .pre-commit-config.yaml
|
|
81
86
|
repos:
|
|
82
87
|
- repo: https://github.com/jonathandung/check-empty
|
|
83
|
-
rev: v1.
|
|
88
|
+
rev: v1.3.0 # repository version
|
|
84
89
|
hooks:
|
|
85
90
|
- id: check-empty # the hook
|
|
86
91
|
args: # example list of arguments
|
|
@@ -96,7 +101,7 @@ equivalent in `prek.toml` format:
|
|
|
96
101
|
```toml
|
|
97
102
|
[[repos]]
|
|
98
103
|
repo = "https://github.com/jonathandung/check-empty"
|
|
99
|
-
rev = "v1.
|
|
104
|
+
rev = "v1.3.0"
|
|
100
105
|
|
|
101
106
|
[[repos.hooks]]
|
|
102
107
|
id = "check-empty"
|
|
@@ -120,7 +125,7 @@ or (TOML 1.1+):
|
|
|
120
125
|
# using multiline inline tables
|
|
121
126
|
[[repos]]
|
|
122
127
|
repo = "https://github.com/jonathandung/check-empty"
|
|
123
|
-
rev = "v1.
|
|
128
|
+
rev = "v1.3.0"
|
|
124
129
|
hooks = [{
|
|
125
130
|
id = "check-empty",
|
|
126
131
|
args = ["-Q"],
|
|
@@ -140,12 +145,12 @@ As a GitHub Actions workflow step:
|
|
|
140
145
|
|
|
141
146
|
```yaml
|
|
142
147
|
steps:
|
|
143
|
-
- uses: jonathandung/check-empty@v1.
|
|
148
|
+
- uses: jonathandung/check-empty@v1.3.0 # the latest version on the GitHub Actions
|
|
144
149
|
# marketplace; this step will fail and subsequent jobs will not run if any file is
|
|
145
150
|
# not empty
|
|
146
151
|
with:
|
|
147
152
|
python-version: '3.14' # run the script on the latest stable Python version
|
|
148
|
-
# Python down to 3.
|
|
153
|
+
# Python down to 3.7 is supported but not recommended due to end-of-life
|
|
149
154
|
verbosity: 1
|
|
150
155
|
filenames: |
|
|
151
156
|
src/mylib/py.typed
|
|
@@ -192,9 +197,10 @@ to read the
|
|
|
192
197
|
and the
|
|
193
198
|
[contributing guide](https://github.com/jonathandung/.github/blob/main/CONTRIBUTING.md).
|
|
194
199
|
|
|
195
|
-
To build the docs locally (needs Python 3.12+), install with the
|
|
196
|
-
[group](https://packaging.python.org/en/latest/specifications/dependency-groups)
|
|
197
|
-
|
|
200
|
+
To build the docs locally (needs Python 3.12+ because of Sphinx), install with the
|
|
201
|
+
`docs` [group](https://packaging.python.org/en/latest/specifications/dependency-groups)
|
|
202
|
+
using a package manager that supports it (e.g. pip 25.1+ or uv 0.4.27+), preferably
|
|
203
|
+
into a virtual environment.
|
|
198
204
|
|
|
199
205
|
Tests are run with:
|
|
200
206
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Copyright © 2026 Jonathan Dung. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Utility to check the emptiness of files and directories.
|
|
4
|
+
|
|
5
|
+
Pre-commit hook, command-line tool and GitHub Action all-in-one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from . import constants, reporter, util
|
|
13
|
+
|
|
14
|
+
if constants.TYPE_CHECKING:
|
|
15
|
+
import typing
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
|
|
18
|
+
from _typeshed import FileDescriptorOrPath
|
|
19
|
+
|
|
20
|
+
__all__ = ('check', 'default_reporter')
|
|
21
|
+
__version__ = '1.3.0'
|
|
22
|
+
default_reporter: reporter.Reporter = reporter.Reporter()
|
|
23
|
+
"""The default reporter used by :func:`check`."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check(
|
|
27
|
+
files: Iterable[FileDescriptorOrPath],
|
|
28
|
+
*,
|
|
29
|
+
clear: bool = False,
|
|
30
|
+
may_not_exist: bool = False,
|
|
31
|
+
verbosity: typing.SupportsIndex = 2,
|
|
32
|
+
recurse_into: typing.SupportsIndex = constants.RecurseInto.NONE,
|
|
33
|
+
reporter: reporter.Reporter = default_reporter,
|
|
34
|
+
) -> int:
|
|
35
|
+
# ruff: ignore[docstring-missing-exception]
|
|
36
|
+
r"""Check the emptiness of files, recursing into directories if passed.
|
|
37
|
+
|
|
38
|
+
If using a custom reporter, remember to pass a ``verbosity`` greater than or equal
|
|
39
|
+
to 5, since some info will be missing from the parameters passed to
|
|
40
|
+
:meth:`Reporter.report` otherwise.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
files: an iterable of file descriptors or paths representing the files and
|
|
44
|
+
directories to check; directory descriptors (\*nix) are not supported.
|
|
45
|
+
clear: if ``True``, clear the contents of non-empty files; directories, notably,
|
|
46
|
+
are not purged, but all files within should become empty.
|
|
47
|
+
may_not_exist: if ``True``, do not treat absent files or directories as errors.
|
|
48
|
+
verbosity: how much detail the program should print to stdout.
|
|
49
|
+
recurse_into: a bitwise or of flags indicating which types of archives to
|
|
50
|
+
recurse into; by default, all archives are treated as regular files.
|
|
51
|
+
reporter: The reporter to use for reporting the results. All output of this
|
|
52
|
+
function is produced by the reporter.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The integer exit code calculated by the reporter.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
files = list(files)
|
|
59
|
+
if not files:
|
|
60
|
+
return 0
|
|
61
|
+
k, j, t, n, o = [0] * 5, [], 0, len(files), files.pop
|
|
62
|
+
u, e, _ = j.extend, j.pop, lambda v: lambda _, k=k: k.__setitem__(v, k[v] + 1)
|
|
63
|
+
verbosity, recurse_into = (type(x).__index__(x) for x in (verbosity, recurse_into))
|
|
64
|
+
# ruff: disable[magic-value-comparison]
|
|
65
|
+
if verbosity > 4:
|
|
66
|
+
b = ['']
|
|
67
|
+
f = b.append
|
|
68
|
+
else:
|
|
69
|
+
b, f = None, _(0)
|
|
70
|
+
if verbosity > 3:
|
|
71
|
+
v, ra = [''], ['']
|
|
72
|
+
x, aa = v.append, ra.append
|
|
73
|
+
else:
|
|
74
|
+
v = ra = None
|
|
75
|
+
x = aa = lambda _: None
|
|
76
|
+
if verbosity > 2:
|
|
77
|
+
z, w = [''], ['']
|
|
78
|
+
i = z.append, w.append
|
|
79
|
+
else:
|
|
80
|
+
z = w = None
|
|
81
|
+
i = _(1), _(2)
|
|
82
|
+
if verbosity > 1:
|
|
83
|
+
r = []
|
|
84
|
+
g = r.append
|
|
85
|
+
else:
|
|
86
|
+
r, g = None, _(3)
|
|
87
|
+
# ruff: enable[magic-value-comparison]
|
|
88
|
+
zp, tr, rr, z7, lh = map(
|
|
89
|
+
recurse_into.__and__,
|
|
90
|
+
(x for x in constants.RecurseInto if x > 0 == x & (x - 1))
|
|
91
|
+
if __import__('sys').version_info < (3, 11)
|
|
92
|
+
else constants.RecurseInto,
|
|
93
|
+
)
|
|
94
|
+
an = bool(recurse_into)
|
|
95
|
+
if an:
|
|
96
|
+
from . import archives as ar
|
|
97
|
+
md = 'a' if clear else 'r'
|
|
98
|
+
if constants.TYPE_CHECKING:
|
|
99
|
+
import tarfile as tr
|
|
100
|
+
import zipfile as zp
|
|
101
|
+
|
|
102
|
+
import lhafile as lh
|
|
103
|
+
import py7zr as z7
|
|
104
|
+
import rarfile as rr
|
|
105
|
+
else:
|
|
106
|
+
if tr:
|
|
107
|
+
import tarfile as tr
|
|
108
|
+
if zp:
|
|
109
|
+
import zipfile as zp
|
|
110
|
+
if lh:
|
|
111
|
+
import lhafile as lh
|
|
112
|
+
if z7:
|
|
113
|
+
import py7zr as z7
|
|
114
|
+
if rr:
|
|
115
|
+
import rarfile as rr
|
|
116
|
+
|
|
117
|
+
def ha(a: str) -> bool:
|
|
118
|
+
if not an:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def c() -> None:
|
|
122
|
+
nonlocal t
|
|
123
|
+
g(q)
|
|
124
|
+
t += s
|
|
125
|
+
|
|
126
|
+
def e() -> None:
|
|
127
|
+
c() if s else f(a)
|
|
128
|
+
|
|
129
|
+
def w() -> None:
|
|
130
|
+
if s:
|
|
131
|
+
if clear:
|
|
132
|
+
raise NotImplementedError(m)
|
|
133
|
+
c()
|
|
134
|
+
else:
|
|
135
|
+
f(a)
|
|
136
|
+
|
|
137
|
+
y = []
|
|
138
|
+
if tr and util.try_tar(a, y.append):
|
|
139
|
+
d = y.pop()
|
|
140
|
+
aa(a)
|
|
141
|
+
for r in d.getmembers():
|
|
142
|
+
a, s = q = r.name, r.size
|
|
143
|
+
e()
|
|
144
|
+
if clear:
|
|
145
|
+
ar.clear_tar(d)
|
|
146
|
+
elif zp and zp.is_zipfile(a):
|
|
147
|
+
with zp.ZipFile(a, md) as d:
|
|
148
|
+
aa(a)
|
|
149
|
+
for r in d.infolist():
|
|
150
|
+
a, s = q = r.filename, r.file_size
|
|
151
|
+
if s:
|
|
152
|
+
c()
|
|
153
|
+
if clear:
|
|
154
|
+
ar.clear_file_in_zip(d, r)
|
|
155
|
+
else:
|
|
156
|
+
f(a)
|
|
157
|
+
elif z7 and z7.is_7zfile(a):
|
|
158
|
+
with z7.SevenZipFile(a) as d:
|
|
159
|
+
aa(a)
|
|
160
|
+
for r in d.files:
|
|
161
|
+
a, s = q = r.filename, r.origin.stat().st_size
|
|
162
|
+
e()
|
|
163
|
+
if clear:
|
|
164
|
+
ar.clear_7z(d)
|
|
165
|
+
elif rr and rr.is_rarfile(a):
|
|
166
|
+
m = 'Cannot clear files in RAR archives'
|
|
167
|
+
with rr.RarFile(a) as d:
|
|
168
|
+
aa(a)
|
|
169
|
+
for r in d.infolist():
|
|
170
|
+
a, s = q = r.filename, r.file_size
|
|
171
|
+
w()
|
|
172
|
+
else:
|
|
173
|
+
try:
|
|
174
|
+
d = lh.LhaFile(a)
|
|
175
|
+
except (RuntimeError, lh.BadLhafile):
|
|
176
|
+
return False
|
|
177
|
+
m = 'Cannot clear files in LHA archives'
|
|
178
|
+
aa(a)
|
|
179
|
+
for r in d.filelist:
|
|
180
|
+
a, s = q = r.filename, r.file_size
|
|
181
|
+
w()
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
while files:
|
|
185
|
+
a, q = o(), None
|
|
186
|
+
m = isinstance(a, int)
|
|
187
|
+
if not m:
|
|
188
|
+
a = os.fsdecode(a)
|
|
189
|
+
if ha(a):
|
|
190
|
+
continue
|
|
191
|
+
with util.Handler(*i, a) as h:
|
|
192
|
+
q = os.stat(a) # ruff: ignore[os-stat]
|
|
193
|
+
if q is None:
|
|
194
|
+
continue
|
|
195
|
+
c = h.e
|
|
196
|
+
if q.st_mode & constants.TYPE_MASK == constants.S_IFDIR:
|
|
197
|
+
if m:
|
|
198
|
+
raise constants.DIRFD_UNSUPPORTED
|
|
199
|
+
x(c)
|
|
200
|
+
u(os.scandir(c))
|
|
201
|
+
continue
|
|
202
|
+
s = q.st_size
|
|
203
|
+
if not s:
|
|
204
|
+
f(c)
|
|
205
|
+
continue
|
|
206
|
+
g((c, s))
|
|
207
|
+
t += s
|
|
208
|
+
if clear:
|
|
209
|
+
h.o()
|
|
210
|
+
del o, files
|
|
211
|
+
while j:
|
|
212
|
+
m = e()
|
|
213
|
+
a = m.path
|
|
214
|
+
if m.is_dir():
|
|
215
|
+
x(a)
|
|
216
|
+
u(os.scandir(a))
|
|
217
|
+
continue
|
|
218
|
+
if ha(a):
|
|
219
|
+
continue
|
|
220
|
+
n += 1
|
|
221
|
+
s = m.stat().st_size
|
|
222
|
+
if not s:
|
|
223
|
+
f(a)
|
|
224
|
+
continue
|
|
225
|
+
g((a, s))
|
|
226
|
+
t += s
|
|
227
|
+
if clear:
|
|
228
|
+
util.Handler(*i, a).o()
|
|
229
|
+
del j
|
|
230
|
+
x, d = k[1] if z is None else len(z) - 1, k[2] if w is None else len(w) - 1
|
|
231
|
+
y, p = k[3] if r is None else len(r), k[0] if b is None else len(b) - 1
|
|
232
|
+
reporter.report(z, w, v, b, r, x, y, d, p, t, n, verbosity, clear)
|
|
233
|
+
reporter.reset_stream()
|
|
234
|
+
return reporter.calculate_result(*map(bool, (y, x and not may_not_exist, d)))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Functions used to clear files in archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .constants import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import zipfile
|
|
9
|
+
from tarfile import TarFile
|
|
10
|
+
|
|
11
|
+
from py7zr import SevenZipFile
|
|
12
|
+
__all__ = ('clear_7z', 'clear_file_in_zip', 'clear_tar')
|
|
13
|
+
_ = __import__('io').BytesIO()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def clear_file_in_zip(z: zipfile.ZipFile, i: zipfile.ZipInfo) -> None:
|
|
17
|
+
"""Clear the contents of a file in a .zip archive."""
|
|
18
|
+
z.writestr(i, b'')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clear_7z(z: SevenZipFile) -> None:
|
|
22
|
+
"""Clear the contents of each file in a .7z archive."""
|
|
23
|
+
x = z.filename
|
|
24
|
+
if x is None:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
y = z.namelist()
|
|
27
|
+
with __import__('py7zr').SevenZipFile(x, 'w') as a:
|
|
28
|
+
for n in y:
|
|
29
|
+
a.writef(_, n)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_tar(t: TarFile) -> None:
|
|
33
|
+
"""Clear the contents of each file in a .tar archive."""
|
|
34
|
+
x = t.name
|
|
35
|
+
if x is None:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
import tarfile as z
|
|
38
|
+
|
|
39
|
+
with __import__('tempfile').NamedTemporaryFile(
|
|
40
|
+
suffix='.tar.gz', delete=False
|
|
41
|
+
) as p, z.open(fileobj=p, mode='w:gz') as f:
|
|
42
|
+
a = f.addfile
|
|
43
|
+
for m in t.getmembers():
|
|
44
|
+
a(z.TarInfo(m.name))
|
|
45
|
+
__import__('os').replace(p.name, x)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Defines constants used by :mod:`check_empty`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
TYPE_CHECKING: bool = False
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import typing
|
|
10
|
+
__all__ = ('DIRFD_UNSUPPORTED', 'ENOENT', 'S_IFDIR', 'TYPE_MASK', 'RecurseInto')
|
|
11
|
+
TYPE_MASK: typing.Final = 0xF000
|
|
12
|
+
"""This mask extracts the type of a file from its mode bits from :func:`os.stat`."""
|
|
13
|
+
S_IFDIR: typing.Final = 0x4000
|
|
14
|
+
"""Equal to :const:`stat.S_IFDIR`."""
|
|
15
|
+
ENOENT: typing.Final = 2
|
|
16
|
+
"""Equal to :const:`errno.ENOENT`."""
|
|
17
|
+
DIRFD_UNSUPPORTED: typing.Final[BaseException] = (
|
|
18
|
+
SystemError('got directory descriptor on Windows')
|
|
19
|
+
if os.name == 'nt'
|
|
20
|
+
else NotImplementedError('directory descriptors are not supported')
|
|
21
|
+
)
|
|
22
|
+
"""The exception raised when a directory descriptor is passed to :func:`check`."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RecurseInto(__import__('enum').IntFlag):
|
|
26
|
+
"""Flags for the ``recurse_into`` argument of :func:`check_empty.check`."""
|
|
27
|
+
|
|
28
|
+
NONE = 0
|
|
29
|
+
"""Don't recurse into any archives; the default behaviour."""
|
|
30
|
+
ZIP = 1
|
|
31
|
+
"""Recurse into .zip archives."""
|
|
32
|
+
TAR = 2
|
|
33
|
+
"""Recurse into .tar archives."""
|
|
34
|
+
RAR = 4
|
|
35
|
+
"""Recurse into .rar archives; requires :mod:`!rarfile`."""
|
|
36
|
+
_7Z = 8
|
|
37
|
+
"""Recurse into .7z archives; requires :mod:`!py7zr`."""
|
|
38
|
+
LZH = 16
|
|
39
|
+
"""Recurse into .lzh and .lha archives; requires :mod:`!lhafile`."""
|
|
40
|
+
ALL = 31
|
|
41
|
+
"""Recurse into all archive types."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
del os
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The reporter API for :func:`~check_empty.check`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys as s
|
|
6
|
+
|
|
7
|
+
from .constants import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
__all__ = ('DelayedReporter', 'Reporter')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Reporter:
|
|
16
|
+
"""Type of all reporters accepted by :func:`check` for the ``reporter`` argument."""
|
|
17
|
+
|
|
18
|
+
__slots__ = ('o',)
|
|
19
|
+
o: typing.IO[str] | None
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
"""Initialize the reporter."""
|
|
23
|
+
self.o = None
|
|
24
|
+
|
|
25
|
+
def to(self, out: typing.IO[str] | None = None) -> typing.IO[str] | None:
|
|
26
|
+
"""Temporarily redirect the output of the reporter for one check.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
out: The output stream to redirect to. If ``None``, the reporter will write
|
|
30
|
+
to :data:`sys.stdout`.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The previous output stream, or ``None`` if no redirection is in effect.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
self.o, o = out, self.o
|
|
37
|
+
return o
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def calculate_result(y: bool, x: bool, d: bool) -> int:
|
|
41
|
+
"""Set the result of the check as :attr:`r`.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The exit code of the check. For the default implementation, this is a
|
|
45
|
+
bitwise or of 1 (some files were not empty), 4 (some files or directories
|
|
46
|
+
were absent and :ref:`-m <check-empty--m>` /
|
|
47
|
+
:ref:`--may-not-exist <check-empty---may-not-exist>` was omitted) and 8
|
|
48
|
+
(caught :exc:`OSError` while processing some files), such that 0 is
|
|
49
|
+
correctly the only return value that represents success. The 2 bit is
|
|
50
|
+
skipped since 2 is the exit code of :class:`argparse.ArgumentParser` when
|
|
51
|
+
it encounters invalid arguments.
|
|
52
|
+
"""
|
|
53
|
+
return y | x << 2 | d << 3
|
|
54
|
+
|
|
55
|
+
def report( # ruff: ignore[too-many-arguments, too-many-positional-arguments]
|
|
56
|
+
self,
|
|
57
|
+
z: list[str] | None,
|
|
58
|
+
w: list[str] | None,
|
|
59
|
+
a: list[str] | None,
|
|
60
|
+
b: list[str] | None,
|
|
61
|
+
r: list[tuple[str, int]] | None,
|
|
62
|
+
x: int,
|
|
63
|
+
y: int,
|
|
64
|
+
d: int,
|
|
65
|
+
p: int,
|
|
66
|
+
t: int,
|
|
67
|
+
n: int,
|
|
68
|
+
v: int,
|
|
69
|
+
c: bool,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Report the results of the check.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
z: List of paths to files that were not found.
|
|
75
|
+
w: List of error messages for files that could not be processed.
|
|
76
|
+
a: List of directories that were recursed into.
|
|
77
|
+
b: List of empty file paths.
|
|
78
|
+
r: List of 2-tuples representing non-empty files and their sizes.
|
|
79
|
+
x: Number of files that were not found.
|
|
80
|
+
y: Number of non-empty files.
|
|
81
|
+
d: Number of I/O errors encountered.
|
|
82
|
+
p: Number of empty files.
|
|
83
|
+
t: Total size of non-empty files in bytes.
|
|
84
|
+
n: Total number of files checked.
|
|
85
|
+
v: The integer value of ``verbosity`` as passed to :func:`check`. For the
|
|
86
|
+
default implementation, verbosity > 5 is equivalent to verbosity = 5, and
|
|
87
|
+
a non-positive verbosity gives no output.
|
|
88
|
+
c: The value of ``clear`` as passed to :func:`check`.
|
|
89
|
+
"""
|
|
90
|
+
if v <= 0:
|
|
91
|
+
return
|
|
92
|
+
q = self.o
|
|
93
|
+
q = (s.stdout if q is None else q).write
|
|
94
|
+
if a is not None:
|
|
95
|
+
q('\nRecursing into directory: '.join(a))
|
|
96
|
+
q('\n')
|
|
97
|
+
if z is not None:
|
|
98
|
+
q('\nNot found: '.join(z))
|
|
99
|
+
q(
|
|
100
|
+
f'{x} file{"s" if x > 1 else ""} not found\n'
|
|
101
|
+
if x
|
|
102
|
+
else 'All files were found\n'
|
|
103
|
+
)
|
|
104
|
+
if w is not None:
|
|
105
|
+
q('\nError: '.join(w))
|
|
106
|
+
if d:
|
|
107
|
+
q(f'{d} I/O error{"s" if d > 1 else ""} encountered\n')
|
|
108
|
+
if b is not None:
|
|
109
|
+
q('\nEmpty: '.join(b))
|
|
110
|
+
if v > 2: # ruff: ignore[magic-value-comparison]
|
|
111
|
+
q(f'\n{p or "No"} empty file{"" if p == 1 else "s"}\n')
|
|
112
|
+
if y:
|
|
113
|
+
if r is not None:
|
|
114
|
+
q(
|
|
115
|
+
('\nCleared: ' if c else '\nNot empty: ').join(
|
|
116
|
+
__import__('itertools').chain(
|
|
117
|
+
('',), map('%s (%d bytes)'.__mod__, r)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
q('\n')
|
|
122
|
+
q(f'{y} offending file{"s" if y > 1 else ""}\nTotal size: {t} bytes\n')
|
|
123
|
+
elif n > x:
|
|
124
|
+
q('All found files were empty\n')
|
|
125
|
+
|
|
126
|
+
def reset_stream(self) -> None:
|
|
127
|
+
"""Reset the reporter, closing the output stream if appropriate."""
|
|
128
|
+
q, self.o = self.o, None
|
|
129
|
+
if q not in {None, s.stdout, s.stderr, s.__stdout__, s.__stderr__}:
|
|
130
|
+
q.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DelayedReporter(Reporter):
|
|
134
|
+
"""Only report the results only when :meth:`do_report` is called."""
|
|
135
|
+
|
|
136
|
+
__slots__ = ('a',)
|
|
137
|
+
|
|
138
|
+
def report(self, *a: typing.Any) -> None: # ruff: ignore[any-type] # ty: ignore[invalid-method-override]
|
|
139
|
+
"""Store the report arguments for later use."""
|
|
140
|
+
self.a = a
|
|
141
|
+
|
|
142
|
+
def do_report(self) -> None:
|
|
143
|
+
"""Conduct the report for the most recent check using this reporter."""
|
|
144
|
+
super().report(*self.a)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Utility functions for :mod:`check_empty`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
|
|
7
|
+
from .constants import ENOENT, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from tarfile import TarFile
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
__all__ = ('try_tar',)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def try_tar(a: str, f: Callable[[TarFile], Any]) -> bool:
|
|
20
|
+
"""Open a file as a .tar archive and return success.
|
|
21
|
+
|
|
22
|
+
Since :func:`tarfile.is_tarfile` calls :func:`tarfile.open` internally and
|
|
23
|
+
discards the result, using the LBYL approach ends up opening the file twice,
|
|
24
|
+
incurring twice the I/O cost.
|
|
25
|
+
Thus, the resultant tarfile is recorded by the callback ``f`` via which the user
|
|
26
|
+
may retrieve it later. The tarfile is not returned directly because the boolean
|
|
27
|
+
return value allows this function to be used as a condition in an if-elif-else
|
|
28
|
+
block, which is integral to the handling of multiple archive formats.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
a: the string path to the file
|
|
32
|
+
f: the callback
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Whether the archive was opened and appended to ``e`` with no errors.
|
|
36
|
+
"""
|
|
37
|
+
import tarfile as m
|
|
38
|
+
|
|
39
|
+
with suppress(m.TarError):
|
|
40
|
+
f(m.open(a)) # ruff: ignore[open-file-with-context-handler]
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Handler:
|
|
46
|
+
__slots__ = 'a', 'c', 'f', 'j', 'v'
|
|
47
|
+
a: int | str
|
|
48
|
+
c: bool
|
|
49
|
+
f: Callable[[str], None]
|
|
50
|
+
j: Callable[[str], None]
|
|
51
|
+
v: str | None
|
|
52
|
+
|
|
53
|
+
def __init__(self, *a: Any) -> None: # ruff: ignore[any-type]
|
|
54
|
+
self.j, self.f, self.a = a
|
|
55
|
+
self.v = None
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> Self:
|
|
58
|
+
self.c = False
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def o(self) -> None:
|
|
62
|
+
with self, open(self.a, 'wb'):
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def e(self, s: str = 'invalid fd (negative): %d', t: str = 'fd: %d') -> str: # ruff: ignore[property-with-parameters]
|
|
67
|
+
r = self.v
|
|
68
|
+
if r is None:
|
|
69
|
+
a = self.a
|
|
70
|
+
self.v = r = (t, s)[a < 0] % a if isinstance(a, int) else a
|
|
71
|
+
return r
|
|
72
|
+
|
|
73
|
+
def __exit__(
|
|
74
|
+
self,
|
|
75
|
+
t: type[BaseException] | None,
|
|
76
|
+
v: BaseException | None,
|
|
77
|
+
_: TracebackType | None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
if t is None or not isinstance(v, OSError):
|
|
80
|
+
return False
|
|
81
|
+
self.j(self.e) if v.errno == ENOENT else self.f(str(v))
|
|
82
|
+
self.c = True
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
del TYPE_CHECKING
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[dependency-groups]
|
|
@@ -57,8 +57,8 @@ license-files = [
|
|
|
57
57
|
]
|
|
58
58
|
name = "check-empty"
|
|
59
59
|
readme = "README.md"
|
|
60
|
-
requires-python = ">=3.
|
|
61
|
-
version = "1.
|
|
60
|
+
requires-python = ">=3.7"
|
|
61
|
+
version = "1.3.0"
|
|
62
62
|
|
|
63
63
|
[[project.authors]]
|
|
64
64
|
name = "Jonathan Dung"
|
|
@@ -68,11 +68,25 @@ email = "jonathandung@yahoo.com"
|
|
|
68
68
|
name = "Jonathan Dung"
|
|
69
69
|
email = "jonathandung@yahoo.com"
|
|
70
70
|
|
|
71
|
+
[project.optional-dependencies]
|
|
72
|
+
7z = [
|
|
73
|
+
"py7zr==1.1.3; python_version >= '3.10'",
|
|
74
|
+
"py7zr==1.0.0; python_version == '3.9'",
|
|
75
|
+
"py7zr==0.22.0; python_version == '3.8'",
|
|
76
|
+
"py7zr==0.20.6; python_version == '3.7'",
|
|
77
|
+
]
|
|
78
|
+
all = ["check-empty[7z,lzh,rar]==1.3.0"]
|
|
79
|
+
lzh = ["lhafile==0.3.1"]
|
|
80
|
+
rar = [
|
|
81
|
+
"rarfile==4.5; python_version >= '3.10'",
|
|
82
|
+
"rarfile==4.2; python_version < '3.10'",
|
|
83
|
+
]
|
|
84
|
+
|
|
71
85
|
[project.scripts]
|
|
72
86
|
check-empty = "check_empty.__main__:main"
|
|
73
87
|
|
|
74
88
|
[tool.ruff]
|
|
75
|
-
required-version = ">=0.16.
|
|
89
|
+
required-version = ">=0.16.7"
|
|
76
90
|
target-version = "py37"
|
|
77
91
|
|
|
78
92
|
[tool.ruff.analyze]
|
|
@@ -83,19 +97,17 @@ quote-style = "single"
|
|
|
83
97
|
|
|
84
98
|
[tool.ruff.lint]
|
|
85
99
|
ignore = [
|
|
86
|
-
"ANN",
|
|
87
100
|
"COM",
|
|
88
101
|
"FBT",
|
|
89
102
|
"PT",
|
|
90
|
-
"PTH",
|
|
91
103
|
"avoidable-escaped-quote",
|
|
92
104
|
"bad-quotes-inline-string",
|
|
105
|
+
"builtin-open",
|
|
93
106
|
"complex-structure",
|
|
94
107
|
"docstring-extraneous-exception",
|
|
95
108
|
"implicit-namespace-package",
|
|
96
109
|
"import-outside-top-level",
|
|
97
110
|
"incorrect-blank-line-before-class",
|
|
98
|
-
"magic-value-comparison",
|
|
99
111
|
"multi-line-summary-second-line",
|
|
100
112
|
"non-empty-init-module",
|
|
101
113
|
"print",
|
|
@@ -111,12 +123,20 @@ select = ["ALL"]
|
|
|
111
123
|
combine-as-imports = true
|
|
112
124
|
|
|
113
125
|
[tool.ruff.lint.per-file-ignores]
|
|
114
|
-
"test_check_empty.py" = [
|
|
126
|
+
"test_check_empty.py" = [
|
|
127
|
+
"ANN",
|
|
128
|
+
"D",
|
|
129
|
+
]
|
|
115
130
|
"!__init__.py" = ["missing-copyright-notice"]
|
|
131
|
+
"__main__.py" = ["missing-return-type-private-function"]
|
|
116
132
|
|
|
117
133
|
[tool.ruff.lint.pydocstyle]
|
|
118
134
|
convention = "google"
|
|
119
135
|
|
|
136
|
+
[tool.ruff.lint.pylint]
|
|
137
|
+
max-args = 6
|
|
138
|
+
max-nested-blocks = 6
|
|
139
|
+
|
|
120
140
|
[tool.ty.analysis]
|
|
121
141
|
respect-type-ignore-comments = false
|
|
122
142
|
|
|
@@ -128,8 +148,29 @@ python-platform = "all"
|
|
|
128
148
|
all = "error"
|
|
129
149
|
missing-override-decorator = "ignore"
|
|
130
150
|
|
|
151
|
+
[[tool.ty.overrides]]
|
|
152
|
+
include = ["check_empty/__init__.py"]
|
|
153
|
+
|
|
154
|
+
[tool.ty.overrides.rules]
|
|
155
|
+
redundant-condition = "ignore"
|
|
156
|
+
unsound-assignment = "ignore"
|
|
157
|
+
|
|
131
158
|
[tool.uv]
|
|
132
|
-
required-version = ">=0.12.
|
|
159
|
+
required-version = ">=0.12.13"
|
|
160
|
+
|
|
161
|
+
[tool.uv.audit]
|
|
162
|
+
ignore = [
|
|
163
|
+
"GHSA-gjrg-mpp7-g774",
|
|
164
|
+
"GHSA-h4gh-22qq-72r7",
|
|
165
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
166
|
+
"GHSA-gjrg-mpp7-g774",
|
|
167
|
+
"GHSA-h4gh-22qq-72r7",
|
|
168
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
169
|
+
"GHSA-gjrg-mpp7-g774",
|
|
170
|
+
"GHSA-h4gh-22qq-72r7",
|
|
171
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
172
|
+
"GHSA-jfmj-5v4g-7637",
|
|
173
|
+
]
|
|
133
174
|
|
|
134
175
|
[tool.uv.build-backend]
|
|
135
176
|
module-name = "check_empty"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[dependency-groups]
|
|
@@ -15,14 +15,20 @@ license-files = ["LICENSE", "AUTHORS.md"]
|
|
|
15
15
|
maintainers = [{name = "Jonathan Dung", email = "jonathandung@yahoo.com"}]
|
|
16
16
|
name = "check-empty"
|
|
17
17
|
readme = "README.md"
|
|
18
|
-
requires-python = ">=3.
|
|
19
|
-
version = "1.
|
|
18
|
+
requires-python = ">=3.7"
|
|
19
|
+
version = "1.3.0"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
"7z" = ["py7zr==1.1.3; python_version >= '3.10'", "py7zr==1.0.0; python_version == '3.9'", "py7zr==0.22.0; python_version == '3.8'", "py7zr==0.20.6; python_version == '3.7'"]
|
|
23
|
+
all = ["check-empty[7z,lzh,rar]==1.3.0"]
|
|
24
|
+
lzh = ["lhafile==0.3.1"]
|
|
25
|
+
rar = ["rarfile==4.5; python_version >= '3.10'", "rarfile==4.2; python_version < '3.10'"]
|
|
20
26
|
|
|
21
27
|
[project.scripts]
|
|
22
28
|
check-empty = "check_empty.__main__:main"
|
|
23
29
|
|
|
24
30
|
[tool.ruff]
|
|
25
|
-
required-version = ">=0.16.
|
|
31
|
+
required-version = ">=0.16.7"
|
|
26
32
|
target-version = "py37"
|
|
27
33
|
|
|
28
34
|
[tool.ruff.analyze]
|
|
@@ -32,7 +38,7 @@ type-checking-imports = false
|
|
|
32
38
|
quote-style = "single"
|
|
33
39
|
|
|
34
40
|
[tool.ruff.lint]
|
|
35
|
-
ignore = ["
|
|
41
|
+
ignore = ["COM", "FBT", "PT", "avoidable-escaped-quote", "bad-quotes-inline-string", "builtin-open", "complex-structure", "docstring-extraneous-exception", "implicit-namespace-package", "import-outside-top-level", "incorrect-blank-line-before-class", "multi-line-summary-second-line", "non-empty-init-module", "print", "too-many-branches", "too-many-locals", "too-many-statements", "unnecessary-dunder-call"]
|
|
36
42
|
preview = true
|
|
37
43
|
select = ["ALL"]
|
|
38
44
|
|
|
@@ -40,12 +46,17 @@ select = ["ALL"]
|
|
|
40
46
|
combine-as-imports = true
|
|
41
47
|
|
|
42
48
|
[tool.ruff.lint.per-file-ignores]
|
|
43
|
-
"test_check_empty.py" = ["D"]
|
|
49
|
+
"test_check_empty.py" = ["ANN", "D"]
|
|
44
50
|
"!__init__.py" = ["missing-copyright-notice"]
|
|
51
|
+
"__main__.py" = ["missing-return-type-private-function"]
|
|
45
52
|
|
|
46
53
|
[tool.ruff.lint.pydocstyle]
|
|
47
54
|
convention = "google"
|
|
48
55
|
|
|
56
|
+
[tool.ruff.lint.pylint]
|
|
57
|
+
max-args = 6
|
|
58
|
+
max-nested-blocks = 6
|
|
59
|
+
|
|
49
60
|
[tool.ty.analysis]
|
|
50
61
|
respect-type-ignore-comments = false
|
|
51
62
|
|
|
@@ -57,8 +68,26 @@ python-platform = "all"
|
|
|
57
68
|
all = "error"
|
|
58
69
|
missing-override-decorator = "ignore"
|
|
59
70
|
|
|
71
|
+
[[tool.ty.overrides]]
|
|
72
|
+
include = ["check_empty/__init__.py"]
|
|
73
|
+
rules = {redundant-condition = "ignore", unsound-assignment = "ignore"}
|
|
74
|
+
|
|
60
75
|
[tool.uv]
|
|
61
|
-
required-version = ">=0.12.
|
|
76
|
+
required-version = ">=0.12.13"
|
|
77
|
+
|
|
78
|
+
[tool.uv.audit]
|
|
79
|
+
ignore = [
|
|
80
|
+
"GHSA-gjrg-mpp7-g774",
|
|
81
|
+
"GHSA-h4gh-22qq-72r7",
|
|
82
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
83
|
+
"GHSA-gjrg-mpp7-g774",
|
|
84
|
+
"GHSA-h4gh-22qq-72r7",
|
|
85
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
86
|
+
"GHSA-gjrg-mpp7-g774",
|
|
87
|
+
"GHSA-h4gh-22qq-72r7",
|
|
88
|
+
"GHSA-q6rc-2cgv-63h7",
|
|
89
|
+
"GHSA-jfmj-5v4g-7637",
|
|
90
|
+
]
|
|
62
91
|
|
|
63
92
|
[tool.uv.build-backend]
|
|
64
93
|
module-name = "check_empty"
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
# Copyright © 2026 Jonathan Dung. All rights reserved.
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
"""Utility to check the emptiness of files and directories.
|
|
4
|
-
|
|
5
|
-
Pre-commit hook, command-line tool and GitHub Action all-in-one.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
from itertools import chain
|
|
13
|
-
|
|
14
|
-
__all__ = ('DelayedReporter', 'Reporter', 'check', 'default_reporter')
|
|
15
|
-
__version__ = '1.2.3'
|
|
16
|
-
|
|
17
|
-
TYPE_CHECKING = False
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
import typing
|
|
20
|
-
from collections.abc import Iterable
|
|
21
|
-
|
|
22
|
-
from _typeshed import FileDescriptorOrPath
|
|
23
|
-
_DIRECTORY_DESCRIPTOR_UNSUPPORTED: BaseException = (
|
|
24
|
-
SystemError('got directory descriptor on Windows')
|
|
25
|
-
if os.name == 'nt'
|
|
26
|
-
else NotImplementedError('directory descriptors are not supported')
|
|
27
|
-
)
|
|
28
|
-
_TYPE_MASK: int = 0xF000
|
|
29
|
-
_S_IFDIR: int = 0x4000
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class _Handler:
|
|
33
|
-
__slots__ = 'a', 'c', 'f', 'j', 'v'
|
|
34
|
-
|
|
35
|
-
def __init__(self, *a):
|
|
36
|
-
self.j, self.f, self.a = a
|
|
37
|
-
self.v = None
|
|
38
|
-
|
|
39
|
-
def __enter__(self):
|
|
40
|
-
self.c = False
|
|
41
|
-
return self
|
|
42
|
-
|
|
43
|
-
def o(self):
|
|
44
|
-
with self, open(self.a, 'wb'):
|
|
45
|
-
...
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def e(self, s='invalid fd (negative): %d', t='fd: %d'): # ruff: ignore[property-with-parameters]
|
|
49
|
-
r = self.v
|
|
50
|
-
if r is None:
|
|
51
|
-
a = self.a
|
|
52
|
-
self.v = r = (t, s)[a < 0] % a if isinstance(a, int) else os.fsdecode(a)
|
|
53
|
-
return r
|
|
54
|
-
|
|
55
|
-
def __exit__(self, t, v, _):
|
|
56
|
-
if t is None or not issubclass(t, OSError):
|
|
57
|
-
return False
|
|
58
|
-
self.j(self.e) if v.errno == 2 else self.f(str(v))
|
|
59
|
-
self.c = True
|
|
60
|
-
return True
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class Reporter:
|
|
64
|
-
"""Type of all reporters that :func:`check` accepts as the ``reporter`` argument."""
|
|
65
|
-
__slots__ = 'o',
|
|
66
|
-
o: typing.IO[str] | None
|
|
67
|
-
|
|
68
|
-
def __init__(self):
|
|
69
|
-
"""Initialize the reporter."""
|
|
70
|
-
self.o = None
|
|
71
|
-
|
|
72
|
-
def to(self, out: typing.IO[str] | None = None) -> typing.IO[str] | None:
|
|
73
|
-
"""Temporarily redirect the output of the reporter for one check.
|
|
74
|
-
|
|
75
|
-
Args:
|
|
76
|
-
out: The output stream to redirect to. If ``None``, the reporter will write
|
|
77
|
-
to :data:`sys.stdout`.
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
The previous output stream.
|
|
81
|
-
|
|
82
|
-
"""
|
|
83
|
-
self.o, o = out, self.o
|
|
84
|
-
return o
|
|
85
|
-
|
|
86
|
-
@staticmethod
|
|
87
|
-
def calculate_result(y: bool, x: bool, d: bool) -> int:
|
|
88
|
-
"""Set the result of the check as :attr:`r`.
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
The exit code of the check. For the default implementation, this is a
|
|
92
|
-
bitwise or of 1 (some files were not empty), 4 (some files or directories
|
|
93
|
-
were absent and :ref:`-m <check-empty--m>` / :ref:`--may-not-exist
|
|
94
|
-
<check-empty---may-not-exist>` was omitted) and 8 (caught :exc:`OSError`
|
|
95
|
-
while processing some files), such that 0 is correctly the only return
|
|
96
|
-
value that represents success. The 2 bit is skipped since 2 is the exit
|
|
97
|
-
code of :class:`argparse.ArgumentParser` when it encounters invalid
|
|
98
|
-
arguments.
|
|
99
|
-
"""
|
|
100
|
-
return y | x << 2 | d << 3
|
|
101
|
-
|
|
102
|
-
def report(self, z, w, r, x, y, d, p, a, b, t, n, v, c): # ruff: ignore[too-many-arguments, too-many-positional-arguments]
|
|
103
|
-
"""Report the results of the check.
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
z: List of files that were not found.
|
|
107
|
-
w: List of error messages for files that could not be processed.
|
|
108
|
-
r: List of 2-tuples representing non-empty files and their sizes.
|
|
109
|
-
x: Number of files that were not found.
|
|
110
|
-
y: Number of non-empty files.
|
|
111
|
-
d: Number of I/O errors encountered.
|
|
112
|
-
p: Number of empty files.
|
|
113
|
-
a: List of directories that were recursed into.
|
|
114
|
-
b: List of empty file paths.
|
|
115
|
-
t: Total size of non-empty files in bytes.
|
|
116
|
-
n: Total number of files checked.
|
|
117
|
-
v: The value of ``verbosity`` as passed to :func:`check`. For the default
|
|
118
|
-
implementation, verbosity > 5 is equivalent to verbosity = 5, and a
|
|
119
|
-
non-positive verbosity gives no output.
|
|
120
|
-
c: The value of ``clear`` as passed to :func:`check`.
|
|
121
|
-
"""
|
|
122
|
-
q = self.o
|
|
123
|
-
q = (sys.stdout if q is None else q).write
|
|
124
|
-
if a is not None:
|
|
125
|
-
q('\nRecursing into directory: '.join(a))
|
|
126
|
-
q('\n')
|
|
127
|
-
if z is not None:
|
|
128
|
-
q('\nNot found: '.join(z))
|
|
129
|
-
q(
|
|
130
|
-
f'{x} file{"s" if x > 1 else ""} not found\n'
|
|
131
|
-
if x
|
|
132
|
-
else 'All files were found\n'
|
|
133
|
-
)
|
|
134
|
-
if w is not None:
|
|
135
|
-
q('\nError: '.join(w))
|
|
136
|
-
if d:
|
|
137
|
-
q(f'{d} I/O error{"s" if d > 1 else ""} encountered\n')
|
|
138
|
-
if b is not None:
|
|
139
|
-
q('\nEmpty: '.join(b))
|
|
140
|
-
if v > 2:
|
|
141
|
-
q(f'\n{p or "No"} empty file{"" if p == 1 else "s"}\n')
|
|
142
|
-
if y:
|
|
143
|
-
if r is not None:
|
|
144
|
-
q(
|
|
145
|
-
('\nCleared: ' if c else '\nNot empty: ').join(
|
|
146
|
-
chain(('',), map('%s (%d bytes)'.__mod__, r))
|
|
147
|
-
)
|
|
148
|
-
)
|
|
149
|
-
q('\n')
|
|
150
|
-
q(f'{y} offending file{"s" if y > 1 else ""}\nTotal size: {t} bytes\n')
|
|
151
|
-
elif n > x:
|
|
152
|
-
q('All found files were empty\n')
|
|
153
|
-
|
|
154
|
-
def reset_stream(self) -> None:
|
|
155
|
-
"""Reset the reporter, closing the output stream if appropriate."""
|
|
156
|
-
q, self.o = self.o, None
|
|
157
|
-
if q not in {None, sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__}:
|
|
158
|
-
q.close()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
class DelayedReporter(Reporter):
|
|
162
|
-
"""Only report the results only when :meth:`do_report` is called."""
|
|
163
|
-
__slots__ = ('a',)
|
|
164
|
-
|
|
165
|
-
def report(self, *a): # ty: ignore[invalid-method-override]
|
|
166
|
-
"""Store the report arguments for later use."""
|
|
167
|
-
self.a = a
|
|
168
|
-
|
|
169
|
-
def do_report(self) -> None:
|
|
170
|
-
"""Conduct the report for the most recent check using this reporter."""
|
|
171
|
-
super().report(*self.a)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
default_reporter: Reporter = Reporter()
|
|
175
|
-
"""The default reporter used by :func:`check`."""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def check(
|
|
179
|
-
files: Iterable[FileDescriptorOrPath],
|
|
180
|
-
*,
|
|
181
|
-
clear: bool = False,
|
|
182
|
-
may_not_exist: bool = False,
|
|
183
|
-
verbosity: typing.SupportsIndex = 2,
|
|
184
|
-
reporter: Reporter = default_reporter,
|
|
185
|
-
) -> int:
|
|
186
|
-
"""Check the emptiness of files, recursing into directories if passed.
|
|
187
|
-
|
|
188
|
-
Args:
|
|
189
|
-
files: an iterable of file descriptors or paths representing the files and
|
|
190
|
-
directories to check; directory descriptors (*nix) are not supported.
|
|
191
|
-
clear: if ``True``, clear the contents of non-empty files; directories, notably,
|
|
192
|
-
are not purged, but all files within should become empty.
|
|
193
|
-
may_not_exist: if ``True``, do not treat absent files or directories as errors.
|
|
194
|
-
verbosity: how much detail the program should print to stdout.
|
|
195
|
-
reporter: The reporter to use for reporting the results. All output of this
|
|
196
|
-
function is produced by the reporter.
|
|
197
|
-
|
|
198
|
-
Returns:
|
|
199
|
-
The integer exit code calculated by the reporter.
|
|
200
|
-
"""
|
|
201
|
-
files = list(files)
|
|
202
|
-
if not files:
|
|
203
|
-
return 0
|
|
204
|
-
k, j, t, n, o = [0] * 4, [], 0, len(files), files.pop
|
|
205
|
-
u, e, _ = j.extend, j.pop, lambda v: lambda _, k=k: k.__setitem__(v, k[v] + 1)
|
|
206
|
-
verbosity = max(0, type(verbosity).__index__(verbosity))
|
|
207
|
-
if verbosity > 4:
|
|
208
|
-
b = ['']
|
|
209
|
-
f = b.append
|
|
210
|
-
else:
|
|
211
|
-
b, f = None, _(0)
|
|
212
|
-
if verbosity > 3:
|
|
213
|
-
v = ['']
|
|
214
|
-
x = v.append
|
|
215
|
-
else:
|
|
216
|
-
v, x = None, lambda _: None
|
|
217
|
-
if verbosity > 2:
|
|
218
|
-
z, w = [''], ['']
|
|
219
|
-
i = z.append, w.append
|
|
220
|
-
else:
|
|
221
|
-
z = w = None
|
|
222
|
-
i = _(1), _(2)
|
|
223
|
-
if verbosity > 1:
|
|
224
|
-
r = []
|
|
225
|
-
g = r.append
|
|
226
|
-
else:
|
|
227
|
-
r, g = None, _(3)
|
|
228
|
-
while files:
|
|
229
|
-
a = o()
|
|
230
|
-
with _Handler(*i, a) as h:
|
|
231
|
-
q = os.stat(a)
|
|
232
|
-
if h.c:
|
|
233
|
-
continue
|
|
234
|
-
c = h.e
|
|
235
|
-
if q.st_mode & _TYPE_MASK == _S_IFDIR:
|
|
236
|
-
if isinstance(a, int):
|
|
237
|
-
raise _DIRECTORY_DESCRIPTOR_UNSUPPORTED
|
|
238
|
-
x(c)
|
|
239
|
-
u(os.scandir(c))
|
|
240
|
-
continue
|
|
241
|
-
s = q.st_size
|
|
242
|
-
if not s:
|
|
243
|
-
f(c)
|
|
244
|
-
continue
|
|
245
|
-
g((c, s))
|
|
246
|
-
t += s
|
|
247
|
-
if clear:
|
|
248
|
-
h.o()
|
|
249
|
-
del o, files
|
|
250
|
-
while j:
|
|
251
|
-
m = e()
|
|
252
|
-
a = m.path
|
|
253
|
-
if m.is_dir():
|
|
254
|
-
x(a)
|
|
255
|
-
u(os.scandir(a))
|
|
256
|
-
continue
|
|
257
|
-
n += 1
|
|
258
|
-
s = m.stat().st_size
|
|
259
|
-
if not s:
|
|
260
|
-
f(a)
|
|
261
|
-
continue
|
|
262
|
-
g((a, s))
|
|
263
|
-
t += s
|
|
264
|
-
if clear:
|
|
265
|
-
_Handler(*i, a).o()
|
|
266
|
-
x = k[1] if z is None else len(z) - 1
|
|
267
|
-
d = k[2] if w is None else len(w) - 1
|
|
268
|
-
y = k[3] if r is None else len(r)
|
|
269
|
-
if verbosity:
|
|
270
|
-
p = k[0] if b is None else len(b) - 1
|
|
271
|
-
reporter.report(z, w, r, x, y, d, p, v, b, t, n, verbosity, clear)
|
|
272
|
-
reporter.reset_stream()
|
|
273
|
-
return reporter.calculate_result(*map(bool, (y, x and not may_not_exist, d)))
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
del TYPE_CHECKING
|
|
File without changes
|
|
File without changes
|
|
File without changes
|