tidy-gmail 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.
- tidy_gmail/__init__.py +1 -0
- tidy_gmail/_version.py +24 -0
- tidy_gmail/auth.py +71 -0
- tidy_gmail/classify.py +34 -0
- tidy_gmail/cleanup.py +172 -0
- tidy_gmail/cli.py +224 -0
- tidy_gmail/gmail_client.py +201 -0
- tidy_gmail/inventory.py +131 -0
- tidy_gmail/py.typed +0 -0
- tidy_gmail/reports.py +183 -0
- tidy_gmail/restore.py +146 -0
- tidy_gmail/rules.py +406 -0
- tidy_gmail/scan_state.py +335 -0
- tidy_gmail/storage.py +255 -0
- tidy_gmail/templates/config.toml +24 -0
- tidy_gmail/templates/sender_decisions.csv +1 -0
- tidy_gmail/templates/senders_always_delete.csv +1 -0
- tidy_gmail/templates/senders_protected.csv +1 -0
- tidy_gmail-0.1.0.dist-info/METADATA +518 -0
- tidy_gmail-0.1.0.dist-info/RECORD +23 -0
- tidy_gmail-0.1.0.dist-info/WHEEL +4 -0
- tidy_gmail-0.1.0.dist-info/entry_points.txt +2 -0
- tidy_gmail-0.1.0.dist-info/licenses/LICENSE +21 -0
tidy_gmail/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local-first, data-driven Gmail cleanup tool."""
|
tidy_gmail/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
tidy_gmail/auth.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Gmail OAuth authorization and local credential management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def connect(root: Path) -> Any:
|
|
14
|
+
"""Authorize the user's own desktop OAuth client, stored locally."""
|
|
15
|
+
from google.auth.transport.requests import Request
|
|
16
|
+
from google.oauth2.credentials import Credentials
|
|
17
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
18
|
+
from googleapiclient.discovery import build
|
|
19
|
+
|
|
20
|
+
secret = root / ".local" / "credentials.json"
|
|
21
|
+
token = root / ".local" / "token.json"
|
|
22
|
+
|
|
23
|
+
if not secret.is_file():
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
f"Missing {secret}. Create a Google Cloud Desktop OAuth "
|
|
26
|
+
"client with Gmail API enabled and save its JSON here."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
credentials = None
|
|
30
|
+
|
|
31
|
+
if token.is_file():
|
|
32
|
+
credentials = Credentials.from_authorized_user_file(
|
|
33
|
+
str(token),
|
|
34
|
+
SCOPES,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if not credentials or not credentials.valid:
|
|
38
|
+
if credentials and credentials.expired and credentials.refresh_token:
|
|
39
|
+
credentials.refresh(Request())
|
|
40
|
+
else:
|
|
41
|
+
credentials = InstalledAppFlow.from_client_secrets_file(
|
|
42
|
+
str(secret),
|
|
43
|
+
SCOPES,
|
|
44
|
+
).run_local_server(port=0)
|
|
45
|
+
|
|
46
|
+
# WHY: Create private temporary storage before writing token contents.
|
|
47
|
+
temporary: str | None = None
|
|
48
|
+
try:
|
|
49
|
+
with tempfile.NamedTemporaryFile(
|
|
50
|
+
mode="w",
|
|
51
|
+
dir=token.parent,
|
|
52
|
+
prefix=".token-",
|
|
53
|
+
suffix=".tmp",
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
delete=False,
|
|
56
|
+
) as stream:
|
|
57
|
+
temporary = stream.name
|
|
58
|
+
stream.write(credentials.to_json())
|
|
59
|
+
stream.flush()
|
|
60
|
+
os.fsync(stream.fileno())
|
|
61
|
+
os.replace(temporary, token)
|
|
62
|
+
finally:
|
|
63
|
+
if temporary and os.path.exists(temporary):
|
|
64
|
+
os.unlink(temporary)
|
|
65
|
+
|
|
66
|
+
return build(
|
|
67
|
+
"gmail",
|
|
68
|
+
"v1",
|
|
69
|
+
credentials=credentials,
|
|
70
|
+
cache_discovery=False,
|
|
71
|
+
)
|
tidy_gmail/classify.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Classify inventory using current protection rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .inventory import read_inventory
|
|
8
|
+
from .rules import Settings, eligible
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def select_candidates(
|
|
12
|
+
root: Path,
|
|
13
|
+
settings: Settings,
|
|
14
|
+
protected: set[str],
|
|
15
|
+
decisions: dict[str, str],
|
|
16
|
+
) -> list[dict[str, str]]:
|
|
17
|
+
"""Select messages using current user-maintained rules."""
|
|
18
|
+
rows = read_inventory(root)
|
|
19
|
+
|
|
20
|
+
ids = [row["id"] for row in rows]
|
|
21
|
+
|
|
22
|
+
if len(ids) != len(set(ids)):
|
|
23
|
+
raise ValueError("Duplicate Gmail message IDs in inventory; scan again")
|
|
24
|
+
|
|
25
|
+
return [
|
|
26
|
+
row
|
|
27
|
+
for row in rows
|
|
28
|
+
if eligible(
|
|
29
|
+
row,
|
|
30
|
+
settings,
|
|
31
|
+
protected,
|
|
32
|
+
decisions,
|
|
33
|
+
)
|
|
34
|
+
]
|
tidy_gmail/cleanup.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Execute explicitly confirmed Gmail cleanup operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import gmail_client as gmail
|
|
9
|
+
from . import storage
|
|
10
|
+
from .classify import select_candidates
|
|
11
|
+
from .rules import Settings, eligible, gmail_query, protection_reason, sender_approval
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def confirm(action: str, count: int) -> bool:
|
|
15
|
+
"""Require an explicit confirmation for a modifying batch."""
|
|
16
|
+
required = f"{action} {count}"
|
|
17
|
+
|
|
18
|
+
print(f"Type {required!r} to authorize this batch.")
|
|
19
|
+
|
|
20
|
+
return input("> ").strip() == required
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trash(
|
|
24
|
+
root: Path,
|
|
25
|
+
settings: Settings,
|
|
26
|
+
protected: set[str],
|
|
27
|
+
decisions: dict[str, str],
|
|
28
|
+
*,
|
|
29
|
+
execute: bool,
|
|
30
|
+
limit: int,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Revalidate approved messages before moving them to Trash."""
|
|
33
|
+
if not 1 <= limit <= settings.batch_size:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"--limit must be 1..{settings.batch_size} (config batch_size)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
all_candidates = select_candidates(
|
|
39
|
+
root,
|
|
40
|
+
settings,
|
|
41
|
+
protected,
|
|
42
|
+
decisions,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
proposal = all_candidates[:limit]
|
|
46
|
+
|
|
47
|
+
print(f"Proposed Trash batch: {len(proposal)} of {len(all_candidates)} candidates")
|
|
48
|
+
|
|
49
|
+
if not proposal:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if settings.dry_run or not execute:
|
|
53
|
+
print("DRY RUN. For execution, set cleanup.dry_run=false and pass --execute.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
api = gmail.connect(root)
|
|
57
|
+
|
|
58
|
+
# Recheck Gmail's current category, age, Inbox,
|
|
59
|
+
# starred, important, and attachment search conditions.
|
|
60
|
+
current_ids = set(
|
|
61
|
+
gmail.list_ids(
|
|
62
|
+
api,
|
|
63
|
+
gmail_query(settings),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
approved: list[dict[str, str]] = []
|
|
68
|
+
|
|
69
|
+
for stored in proposal:
|
|
70
|
+
if stored["id"] not in current_ids:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
current = checked_metadata(api, stored, settings, protected, decisions)
|
|
74
|
+
if current is not None:
|
|
75
|
+
approved.append(current)
|
|
76
|
+
|
|
77
|
+
print(f"Still eligible after live Gmail recheck: {len(approved)}")
|
|
78
|
+
|
|
79
|
+
if not approved or not confirm(
|
|
80
|
+
"TRASH",
|
|
81
|
+
len(approved),
|
|
82
|
+
):
|
|
83
|
+
print("Cancelled. No messages changed.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
log = root / "output" / "cleanup_log.csv"
|
|
87
|
+
count = 0
|
|
88
|
+
|
|
89
|
+
for stored in approved:
|
|
90
|
+
# WHY: Attachment and from:me protections rely on current Gmail search.
|
|
91
|
+
try:
|
|
92
|
+
matches = any(
|
|
93
|
+
message_id == stored["id"]
|
|
94
|
+
for message_id in gmail.list_ids(api, gmail_query(settings))
|
|
95
|
+
)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
print(f"Skipped {stored['id']}: search_unavailable ({type(exc).__name__})")
|
|
98
|
+
continue
|
|
99
|
+
if not matches:
|
|
100
|
+
print(f"Skipped {stored['id']}: current Gmail protections")
|
|
101
|
+
continue
|
|
102
|
+
# REQ: Recheck after confirmation, immediately before each mutation.
|
|
103
|
+
row = checked_metadata(api, stored, settings, protected, decisions)
|
|
104
|
+
if row is None:
|
|
105
|
+
continue
|
|
106
|
+
# Log the intention before issuing the Gmail operation.
|
|
107
|
+
storage.audit(
|
|
108
|
+
log,
|
|
109
|
+
"trash_requested",
|
|
110
|
+
row,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
gmail.trash(
|
|
115
|
+
api,
|
|
116
|
+
row["id"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
storage.audit(
|
|
121
|
+
log,
|
|
122
|
+
"trash_failed",
|
|
123
|
+
row,
|
|
124
|
+
type(exc).__name__,
|
|
125
|
+
)
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
storage.audit(
|
|
129
|
+
log,
|
|
130
|
+
"trashed",
|
|
131
|
+
row,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
count += 1
|
|
135
|
+
|
|
136
|
+
print(f"Moved {count} individual messages to Gmail Trash. Audit: {log}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def checked_metadata(
|
|
140
|
+
api: Any,
|
|
141
|
+
stored: dict[str, str],
|
|
142
|
+
settings: Settings,
|
|
143
|
+
protected: set[str],
|
|
144
|
+
decisions: dict[str, str],
|
|
145
|
+
) -> dict[str, str] | None:
|
|
146
|
+
"""Skip messages whose current metadata cannot authorize cleanup safely."""
|
|
147
|
+
message_id = stored["id"]
|
|
148
|
+
try:
|
|
149
|
+
current = gmail.metadata(api, message_id)
|
|
150
|
+
reason = protection_reason(current)
|
|
151
|
+
if not reason and current.get("sender", "").strip().casefold() in protected:
|
|
152
|
+
reason = "sender_protected"
|
|
153
|
+
if not reason:
|
|
154
|
+
approval = sender_approval(current.get("sender", ""), decisions)
|
|
155
|
+
if approval.startswith("conflict:"):
|
|
156
|
+
reason = approval
|
|
157
|
+
if not reason and (
|
|
158
|
+
current.get("id") != message_id
|
|
159
|
+
or current.get("sender") != stored.get("sender")
|
|
160
|
+
or current.get("subject") != stored.get("subject")
|
|
161
|
+
):
|
|
162
|
+
reason = "metadata_changed"
|
|
163
|
+
if not reason and not eligible(current, settings, protected, decisions):
|
|
164
|
+
reason = "existing_protection_or_ineligible"
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
# WHY: API failures and malformed responses are never permission to delete.
|
|
167
|
+
print(f"Skipped {message_id}: metadata_unavailable ({type(exc).__name__})")
|
|
168
|
+
return None
|
|
169
|
+
if reason:
|
|
170
|
+
print(f"Skipped {message_id}: {reason}")
|
|
171
|
+
return None
|
|
172
|
+
return current
|
tidy_gmail/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""cli.py - command line interface.
|
|
2
|
+
|
|
3
|
+
uvx tidy-gmail init
|
|
4
|
+
uvx tidy-gmail doctor
|
|
5
|
+
uvx tidy-gmail scan
|
|
6
|
+
uvx tidy-gmail report
|
|
7
|
+
uvx tidy-gmail candidates
|
|
8
|
+
uvx tidy-gmail trash
|
|
9
|
+
uvx tidy-gmail restore
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import sqlite3
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from . import gmail_client as gmail # noqa: F401
|
|
20
|
+
from . import storage
|
|
21
|
+
from .classify import select_candidates # noqa: F401
|
|
22
|
+
from .cleanup import confirm, trash # noqa: F401
|
|
23
|
+
from .inventory import read_inventory, scan # noqa: F401
|
|
24
|
+
from .reports import candidates, report
|
|
25
|
+
from .restore import restorable, restore # noqa: F401
|
|
26
|
+
from .rules import (
|
|
27
|
+
Settings,
|
|
28
|
+
load_settings,
|
|
29
|
+
sender_rules,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
APP_NAME = "tidy-gmail"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prepare(
|
|
36
|
+
root: Path,
|
|
37
|
+
) -> tuple[Settings, set[str], dict[str, str]]:
|
|
38
|
+
"""Validate the existing workspace without repairing it."""
|
|
39
|
+
storage.check_repos_case(root)
|
|
40
|
+
|
|
41
|
+
for name in ("data", "output", ".local"):
|
|
42
|
+
if not (root / name).is_dir():
|
|
43
|
+
raise FileNotFoundError(f"Missing {root / name}; run tidy-gmail init")
|
|
44
|
+
|
|
45
|
+
settings = load_settings(root / "data" / "config.toml")
|
|
46
|
+
|
|
47
|
+
protected, decisions = sender_rules(root / "data")
|
|
48
|
+
|
|
49
|
+
return settings, protected, decisions
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Build the CLI parser and register every command."""
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog=APP_NAME,
|
|
56
|
+
description="Conservative Gmail cleanup",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--version",
|
|
61
|
+
action="version",
|
|
62
|
+
version="tidy-gmail 0.1.0",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--workspace", type=Path, help="Use an existing local workspace"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
sub = parser.add_subparsers(
|
|
70
|
+
dest="command",
|
|
71
|
+
required=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
init_parser = sub.add_parser(
|
|
75
|
+
"init",
|
|
76
|
+
help=("Create the local workspace without overwriting existing files"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
init_parser.add_argument(
|
|
80
|
+
"--yes",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Create only missing files without a prompt",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
sub.add_parser(
|
|
86
|
+
"doctor",
|
|
87
|
+
help="Validate the local configuration",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
scan_parser = sub.add_parser(
|
|
91
|
+
"scan",
|
|
92
|
+
help="Fetch Gmail metadata and discover senders",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
scan_parser.add_argument(
|
|
96
|
+
"--limit",
|
|
97
|
+
type=int,
|
|
98
|
+
default=5000,
|
|
99
|
+
help="Maximum additional unique messages this invocation (automatically resumes)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
scan_parser.add_argument(
|
|
103
|
+
"--restart",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Archive previous results and begin a fresh scan",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
sub.add_parser(
|
|
109
|
+
"report",
|
|
110
|
+
help="Show sender counts and candidate totals",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
sub.add_parser(
|
|
114
|
+
"candidates",
|
|
115
|
+
help="Save candidate CSV without Gmail modifications",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for command in ("trash", "restore"):
|
|
119
|
+
command_parser = sub.add_parser(
|
|
120
|
+
command,
|
|
121
|
+
help=f"{command.title()} logged Gmail messages",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
command_parser.add_argument(
|
|
125
|
+
"--limit",
|
|
126
|
+
type=int,
|
|
127
|
+
default=None,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
command_parser.add_argument(
|
|
131
|
+
"--execute",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="Request actual mailbox modification",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return parser
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv: list[str] | None = None) -> int:
|
|
140
|
+
"""Execute the requested CLI command."""
|
|
141
|
+
args = build_parser().parse_args(argv)
|
|
142
|
+
root = (
|
|
143
|
+
args.workspace.expanduser().resolve() if args.workspace else storage.workspace()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
if args.command == "init":
|
|
148
|
+
storage.init(
|
|
149
|
+
root,
|
|
150
|
+
confirm=args.yes,
|
|
151
|
+
)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
settings, protected, decisions = prepare(root)
|
|
155
|
+
|
|
156
|
+
if args.command == "doctor":
|
|
157
|
+
print(f"Workspace valid: {root}")
|
|
158
|
+
print("OAuth not tested; no Gmail connection made")
|
|
159
|
+
|
|
160
|
+
elif args.command == "scan":
|
|
161
|
+
scan(
|
|
162
|
+
root,
|
|
163
|
+
settings,
|
|
164
|
+
limit=args.limit,
|
|
165
|
+
restart=args.restart,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
elif args.command == "report":
|
|
169
|
+
report(
|
|
170
|
+
root,
|
|
171
|
+
settings,
|
|
172
|
+
protected,
|
|
173
|
+
decisions,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
elif args.command == "candidates":
|
|
177
|
+
candidates(
|
|
178
|
+
root,
|
|
179
|
+
settings,
|
|
180
|
+
protected,
|
|
181
|
+
decisions,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
elif args.command == "trash":
|
|
185
|
+
trash(
|
|
186
|
+
root,
|
|
187
|
+
settings,
|
|
188
|
+
protected,
|
|
189
|
+
decisions,
|
|
190
|
+
execute=args.execute,
|
|
191
|
+
limit=settings.batch_size if args.limit is None else args.limit,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
elif args.command == "restore":
|
|
195
|
+
restore(
|
|
196
|
+
root,
|
|
197
|
+
execute=args.execute and not settings.dry_run,
|
|
198
|
+
limit=settings.batch_size if args.limit is None else args.limit,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
print(
|
|
203
|
+
"Interrupted. Completed scan work is saved; run scan again to resume.",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 130
|
|
207
|
+
except (
|
|
208
|
+
sqlite3.Error,
|
|
209
|
+
OSError,
|
|
210
|
+
ValueError,
|
|
211
|
+
KeyError,
|
|
212
|
+
EOFError,
|
|
213
|
+
) as exc:
|
|
214
|
+
print(
|
|
215
|
+
f"Error: {exc}",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
sys.exit(main())
|