popafox 0.6.1__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.
- popafox-0.6.1/PKG-INFO +17 -0
- popafox-0.6.1/README.md +0 -0
- popafox-0.6.1/pyproject.toml +40 -0
- popafox-0.6.1/pyproject.toml.orig +42 -0
- popafox-0.6.1/src/popafox/__init__.py +5 -0
- popafox-0.6.1/src/popafox/__main__.py +5 -0
- popafox-0.6.1/src/popafox/cli.py +163 -0
- popafox-0.6.1/src/popafox/config.py +102 -0
- popafox-0.6.1/src/popafox/constants.py +1 -0
- popafox-0.6.1/src/popafox/container.py +43 -0
- popafox-0.6.1/src/popafox/functions.py +149 -0
- popafox-0.6.1/src/popafox/logger.py +103 -0
- popafox-0.6.1/src/popafox/model.py +18 -0
- popafox-0.6.1/src/popafox/patch.py +656 -0
- popafox-0.6.1/src/popafox/templates/firefox-popafox-macos-launcher.j2 +2 -0
- popafox-0.6.1/src/popafox/templates/firefox-popafox.desktop.j2 +9 -0
- popafox-0.6.1/src/popafox/templates/firefox-popafox.j2 +2 -0
- popafox-0.6.1/src/popafox/templates/firefox-popafox.plist.j2 +0 -0
- popafox-0.6.1/src/popafox/templates/firefox-popafox.ps1.j2 +7 -0
popafox-0.6.1/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: popafox
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Utility for patching your firefox, hide webdriver detecting
|
|
5
|
+
Requires-Dist: configparser>=7.2.0
|
|
6
|
+
Requires-Dist: jinja2>=3.1.6
|
|
7
|
+
Requires-Dist: loguru>=0.7.3
|
|
8
|
+
Requires-Dist: pillow>=13.0.0
|
|
9
|
+
Requires-Dist: psutil>=7.2.2
|
|
10
|
+
Requires-Dist: pydantic>=2.14.1
|
|
11
|
+
Requires-Dist: pywin32>=312 ; sys_platform == 'win32' and extra == 'windows'
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Project-URL: Homepage, https://gitflic.ru/project/kurilka/popafox
|
|
14
|
+
Project-URL: Github, https://github.com/TheWhatis/popafox
|
|
15
|
+
Provides-Extra: windows
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
popafox-0.6.1/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "popafox"
|
|
3
|
+
version = "0.6.1"
|
|
4
|
+
description = "Utility for patching your firefox, hide webdriver detecting"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"configparser>=7.2.0",
|
|
9
|
+
"jinja2>=3.1.6",
|
|
10
|
+
"loguru>=0.7.3",
|
|
11
|
+
"pillow>=13.0.0",
|
|
12
|
+
"psutil>=7.2.2",
|
|
13
|
+
"pydantic>=2.14.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
popafox = "popafox:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://gitflic.ru/project/kurilka/popafox"
|
|
21
|
+
Github = "https://github.com/TheWhatis/popafox"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
windows = ["pywin32>=312 ; sys_platform == 'win32'"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"basedpyright>=1.40.2",
|
|
33
|
+
"epc>=0.0.5",
|
|
34
|
+
"six>=1.17.0",
|
|
35
|
+
"twine>=7.0.0",
|
|
36
|
+
"watchdog>=6.0.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.uv.sources.clipik]
|
|
40
|
+
workspace = true
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "popafox"
|
|
3
|
+
version = "0.6.1"
|
|
4
|
+
description = "Utility for patching your firefox, hide webdriver detecting"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"configparser>=7.2.0",
|
|
9
|
+
"jinja2>=3.1.6",
|
|
10
|
+
"loguru>=0.7.3",
|
|
11
|
+
"pillow>=13.0.0",
|
|
12
|
+
"psutil>=7.2.2",
|
|
13
|
+
"pydantic>=2.14.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
popafox = "popafox:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"basedpyright>=1.40.2",
|
|
26
|
+
"epc>=0.0.5",
|
|
27
|
+
"six>=1.17.0",
|
|
28
|
+
"twine>=7.0.0",
|
|
29
|
+
"watchdog>=6.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://gitflic.ru/project/kurilka/popafox"
|
|
34
|
+
Github = "https://github.com/TheWhatis/popafox"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
windows = [
|
|
38
|
+
"pywin32>=312 ; sys_platform == 'win32'",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.uv.sources]
|
|
42
|
+
clipik = { workspace = true }
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import signal
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .patch import patch
|
|
8
|
+
from .model import MainConfig, Config
|
|
9
|
+
from .container import Container
|
|
10
|
+
from .config import write_fresh_config, read_config_file
|
|
11
|
+
from .logger import initialize_logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _add_default_options(parser: argparse.ArgumentParser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
'--version',
|
|
17
|
+
action='version',
|
|
18
|
+
version=f"{Container.program} {Container.version}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
'--config',
|
|
23
|
+
dest='config',
|
|
24
|
+
type=Path,
|
|
25
|
+
default=None,
|
|
26
|
+
metavar='PATH',
|
|
27
|
+
help='Force choice config file',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
'--log-level',
|
|
32
|
+
dest='log_level',
|
|
33
|
+
type=str,
|
|
34
|
+
default=None,
|
|
35
|
+
metavar='LEVEL',
|
|
36
|
+
help='Logging level, default [INFO]'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
'--log-dir',
|
|
41
|
+
dest='log_dir',
|
|
42
|
+
type=Path,
|
|
43
|
+
default=None,
|
|
44
|
+
metavar='PATH',
|
|
45
|
+
help='Force choice log directory',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
'--src',
|
|
50
|
+
dest='src',
|
|
51
|
+
type=Path,
|
|
52
|
+
default=None,
|
|
53
|
+
metavar='PATH',
|
|
54
|
+
help='Source firefox directory'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
'--dst',
|
|
59
|
+
dest='dst',
|
|
60
|
+
type=Path,
|
|
61
|
+
default=None,
|
|
62
|
+
metavar='PATH',
|
|
63
|
+
help='Destination firefox directory (patched)'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
'--no-confirm',
|
|
68
|
+
dest='no_confirm',
|
|
69
|
+
action='store_true',
|
|
70
|
+
help='Skip confirmation prompts'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
'-y', '--yes',
|
|
75
|
+
dest='assume_yes',
|
|
76
|
+
action='store_true',
|
|
77
|
+
help='Assume yes'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog=Container.program,
|
|
84
|
+
description='Synchronize clipboard by network',
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_add_default_options(parser)
|
|
88
|
+
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _initialize_loop(container: Container, log_prefix: str):
|
|
93
|
+
initialize_logger(container.config, log_prefix)
|
|
94
|
+
|
|
95
|
+
if container.fresh_config:
|
|
96
|
+
logger.info('Config has been freshed [{}]', container.fresh_config_path)
|
|
97
|
+
|
|
98
|
+
logger.info('Initialized config')
|
|
99
|
+
for key, value in container.config.model_dump().items():
|
|
100
|
+
logger.info('Config [{}]=[{}]', key, value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _main(container: Container):
|
|
104
|
+
_initialize_loop(container, 'main')
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
patch(
|
|
108
|
+
container.config.src,
|
|
109
|
+
container.config.dst,
|
|
110
|
+
container.config.no_confirm,
|
|
111
|
+
container.config.assume_yes,
|
|
112
|
+
)
|
|
113
|
+
except KeyboardInterrupt:
|
|
114
|
+
print("Bye-bye!!!", file=sys.stderr)
|
|
115
|
+
sys.exit(130)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main():
|
|
119
|
+
parser = _build_parser()
|
|
120
|
+
args = parser.parse_args()
|
|
121
|
+
|
|
122
|
+
def _shutdown_handler(signum, frame):
|
|
123
|
+
raise SystemExit(128 + signum)
|
|
124
|
+
|
|
125
|
+
signal.signal(signal.SIGINT, _shutdown_handler)
|
|
126
|
+
signal.signal(signal.SIGTERM, _shutdown_handler)
|
|
127
|
+
|
|
128
|
+
if os.name == 'nt': # Windows
|
|
129
|
+
config_path = Path(os.getenv('APPDATA', '~')) / 'popafox'
|
|
130
|
+
elif sys.platform == 'darwin': # MacOS
|
|
131
|
+
config_path = Path('~/Library/Application Support/Popafox').expanduser()
|
|
132
|
+
else: # Linux / Unix
|
|
133
|
+
config_path = Path('~/.config/popafox').expanduser()
|
|
134
|
+
|
|
135
|
+
config = MainConfig
|
|
136
|
+
config_path = config_path / 'config.json'
|
|
137
|
+
fresh_config_path = config_path
|
|
138
|
+
fresh_config: Config = write_fresh_config(fresh_config_path, config)
|
|
139
|
+
|
|
140
|
+
overrides = {
|
|
141
|
+
key: value
|
|
142
|
+
for key, value in vars(args).items()
|
|
143
|
+
if value is not None
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
config_path: Path = overrides.get('config', config_path)
|
|
147
|
+
config: Config = read_config_file(config_path, config, overrides)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
container = Container(
|
|
151
|
+
config=config,
|
|
152
|
+
fresh_config=fresh_config,
|
|
153
|
+
fresh_config_path=fresh_config_path,
|
|
154
|
+
config_path=config_path,
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
initialize_logger(config, args.command)
|
|
158
|
+
logger.critical('Error [{}] container initialization', e)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
_main(container)
|
|
162
|
+
|
|
163
|
+
# parser.error('Command not passed, write [mux, server], for details use --help')
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .model import Config, MainConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _build_def_config(config: type[Config]) -> Config:
|
|
9
|
+
log_level = os.getenv('POPAFOX_LOG_LEVEL', default='INFO')
|
|
10
|
+
log_dir = os.getenv('POPAFOX_LOG_DIR', default=None)
|
|
11
|
+
|
|
12
|
+
if log_dir: # Env POPAFOX_LOG_DIR
|
|
13
|
+
log_dir = Path(log_dir).expanduser()
|
|
14
|
+
elif os.name == 'nt': # Windows
|
|
15
|
+
log_dir = Path(os.getenv('APPDATA', '~')) / 'popafox'
|
|
16
|
+
elif sys.platform == 'darwin':
|
|
17
|
+
log_dir = Path('~/Library/Appliction Support/Popafox').expanduser()
|
|
18
|
+
else: # Linux / Unix
|
|
19
|
+
log_dir = Path('~/.local/popafox').expanduser()
|
|
20
|
+
|
|
21
|
+
src = os.getenv('POPAFOX_SRC', default=None)
|
|
22
|
+
|
|
23
|
+
if src:
|
|
24
|
+
src = Path(src).expanduser()
|
|
25
|
+
elif os.name == 'nt': # Windows
|
|
26
|
+
src = Path('C:/Program Files/Firefox')
|
|
27
|
+
elif sys.platform == 'darwin': # MacOS
|
|
28
|
+
raise RuntimeError('MacOS does not supports')
|
|
29
|
+
else: # Linux / Unix
|
|
30
|
+
src = Path('/usr/lib/firefox')
|
|
31
|
+
|
|
32
|
+
dst = os.getenv('POPAFOX_DST', default=None)
|
|
33
|
+
|
|
34
|
+
if dst:
|
|
35
|
+
dst = Path(dst).expanduser()
|
|
36
|
+
elif os.name == 'nt': # Windows
|
|
37
|
+
dst = Path(os.getenv('APPDATA', '~')) / 'Firefox Undetected'
|
|
38
|
+
elif sys.platform == 'darwin': # MacOS
|
|
39
|
+
dst = Path('~/Library/Application Support/Firefox Undetected').expanduser()
|
|
40
|
+
else: # Linux / Unix
|
|
41
|
+
dst = Path('~/.local/firefox_undetected').expanduser()
|
|
42
|
+
|
|
43
|
+
return config(
|
|
44
|
+
log_dir=log_dir,
|
|
45
|
+
log_level=log_level,
|
|
46
|
+
src=src,
|
|
47
|
+
dst=dst,
|
|
48
|
+
no_confirm=True if os.getenv('POPAFOX_NO_CONFIRM', default='False') in ('True', 't', '1', 1) else False,
|
|
49
|
+
assume_yes=True if os.getenv('POPAFOX_ASSUME_YES', default='False') in ('True', 't', '1', 1) else False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_fresh_config(path: Path, config: type[Config]) -> bool:
|
|
54
|
+
"""Создает файл конфига с нуля, если его нет."""
|
|
55
|
+
if path.exists():
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
config: Config = _build_def_config(config)
|
|
61
|
+
payload = config.model_dump(
|
|
62
|
+
mode='json',
|
|
63
|
+
include=set([
|
|
64
|
+
'log_level',
|
|
65
|
+
'log_dir',
|
|
66
|
+
'src',
|
|
67
|
+
'dst',
|
|
68
|
+
'no_confirm',
|
|
69
|
+
'assume_yes',
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
path.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_config_file(
|
|
78
|
+
path: Path,
|
|
79
|
+
config: type[Config],
|
|
80
|
+
overrides: dict[str, object] = {},
|
|
81
|
+
) -> Config:
|
|
82
|
+
if not path.exists():
|
|
83
|
+
write_fresh_config(path, config)
|
|
84
|
+
|
|
85
|
+
raw = path.read_text(encoding='utf-8')
|
|
86
|
+
|
|
87
|
+
def_config = _build_def_config(config)
|
|
88
|
+
merged = {**json.loads(raw), **(overrides if overrides else {})}
|
|
89
|
+
|
|
90
|
+
for key in config.model_fields:
|
|
91
|
+
if key in merged and merged[key]:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
merged[key] = getattr(def_config, key)
|
|
95
|
+
|
|
96
|
+
config = config(**merged)
|
|
97
|
+
|
|
98
|
+
config.src = config.src.expanduser()
|
|
99
|
+
config.dst = config.dst.expanduser()
|
|
100
|
+
config.log_dir = config.log_dir.expanduser()
|
|
101
|
+
|
|
102
|
+
return config
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SEARCH_WEBDRIVER_BINARY = b"webdriver"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tomllib
|
|
5
|
+
from .model import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _resolve_version() -> str:
|
|
9
|
+
try:
|
|
10
|
+
return version('fofan')
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
# fallback: запуск из исходников
|
|
15
|
+
pyproject = Path(__file__).resolve().parent.parent / 'pyproject.toml'
|
|
16
|
+
try:
|
|
17
|
+
with pyproject.open('rb') as f:
|
|
18
|
+
data = tomllib.load(f)
|
|
19
|
+
return data['project']['version']
|
|
20
|
+
except (OSError, KeyError):
|
|
21
|
+
return 'unknown'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Container:
|
|
25
|
+
program: str = 'fofan'
|
|
26
|
+
version: str = _resolve_version()
|
|
27
|
+
|
|
28
|
+
config: Config
|
|
29
|
+
fresh_config: bool
|
|
30
|
+
fresh_config_path: Path
|
|
31
|
+
config_path: Path
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: Config,
|
|
36
|
+
fresh_config: bool,
|
|
37
|
+
fresh_config_path: Path,
|
|
38
|
+
config_path: Path,
|
|
39
|
+
):
|
|
40
|
+
self.config = config
|
|
41
|
+
self.fresh_config = fresh_config
|
|
42
|
+
self.fresh_config_path = fresh_config_path
|
|
43
|
+
self.config_path = config_path
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _ask_via_tkinter(prompt: str) -> bool | None:
|
|
10
|
+
try:
|
|
11
|
+
import tkinter as tk
|
|
12
|
+
from tkinter import ttk
|
|
13
|
+
except ImportError:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
root = tk.Tk()
|
|
17
|
+
root.withdraw()
|
|
18
|
+
|
|
19
|
+
dialog = tk.Toplevel(root)
|
|
20
|
+
dialog.title("Confirmation")
|
|
21
|
+
dialog.geometry("400x150")
|
|
22
|
+
dialog.resizable(False, False)
|
|
23
|
+
dialog.attributes('-topmost', True)
|
|
24
|
+
dialog.focus_force()
|
|
25
|
+
|
|
26
|
+
frame = ttk.Frame(dialog, padding=16)
|
|
27
|
+
frame.pack(fill='both', expand=True)
|
|
28
|
+
ttk.Label(frame, text=prompt, wraplength=360).pack(pady=(0, 16))
|
|
29
|
+
|
|
30
|
+
result = {'answer': False}
|
|
31
|
+
|
|
32
|
+
# root.quit() закрывает mainloop, после чего мы уничтожаем окна
|
|
33
|
+
def _yes():
|
|
34
|
+
result['answer'] = True; root.quit()
|
|
35
|
+
|
|
36
|
+
def _no():
|
|
37
|
+
result['answer'] = False; root.quit()
|
|
38
|
+
|
|
39
|
+
btn_frame = ttk.Frame(frame)
|
|
40
|
+
btn_frame.pack(fill='x')
|
|
41
|
+
ttk.Button(btn_frame, text="Да", command=_yes, width=10).pack(side='right', padx=(8, 0))
|
|
42
|
+
ttk.Button(btn_frame, text="Нет", command=_no, width=10).pack(side='right')
|
|
43
|
+
|
|
44
|
+
dialog.protocol("WM_DELETE_WINDOW", _no)
|
|
45
|
+
dialog.bind('<Return>', lambda e: _yes())
|
|
46
|
+
dialog.bind('<Escape>', lambda e: _no())
|
|
47
|
+
|
|
48
|
+
# Ловим Ctrl+C прямо внутри окна Tkinter
|
|
49
|
+
dialog.bind('<Control-c>', lambda e: _no())
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
root.mainloop()
|
|
53
|
+
except KeyboardInterrupt:
|
|
54
|
+
result['answer'] = False
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
# Уничтожаем всё аккуратно
|
|
58
|
+
try:
|
|
59
|
+
root.destroy()
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
return result['answer']
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ask_via_linux_dialog(prompt: str) -> bool | None:
|
|
67
|
+
"""Пробует zenity или kdialog (работают и в Xorg, и в Wayland)."""
|
|
68
|
+
# zenity: возвращает 0 при Yes, 1 при No
|
|
69
|
+
zenity = shutil.which("zenity")
|
|
70
|
+
|
|
71
|
+
if zenity:
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
[zenity, "--question", "--ok-label=Да", "--cancel-label=Нет", f"--text={prompt}"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
)
|
|
77
|
+
return result.returncode == 0
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error('Zenity error [{}]', e)
|
|
80
|
+
pass
|
|
81
|
+
else:
|
|
82
|
+
logger.info('Zenity does not exists for confirmation')
|
|
83
|
+
|
|
84
|
+
# kdialog: возвращает 0 при Yes, 1 при No
|
|
85
|
+
kdialog = shutil.which("kdialog")
|
|
86
|
+
if kdialog:
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[kdialog, "--yesno", prompt],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
)
|
|
92
|
+
return result.returncode == 0
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error('Kdialog error [{}]', e)
|
|
95
|
+
pass
|
|
96
|
+
else:
|
|
97
|
+
logger.info('Kdialog does not exists for confirmation')
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ask_via_terminal(prompt: str) -> bool:
|
|
103
|
+
"""Последний fallback — обычный ввод в терминале."""
|
|
104
|
+
try:
|
|
105
|
+
answer = input(f"{prompt} [y/N] ").strip().lower()
|
|
106
|
+
except EOFError as e:
|
|
107
|
+
logger.error('Fallback confirmation error [{}]', e)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
return answer in ("y", "yes", "д", "да")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ask_confirmation(prompt: str = "Продолжить?") -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Показывает подтверждение в максимально подходящем для окружения виде:
|
|
116
|
+
- Windows / macOS -> нативное окно tkinter
|
|
117
|
+
- Linux + Wayland -> zenity/kdialog (Wayland-совместимые)
|
|
118
|
+
- Linux + Xorg -> zenity/kdialog (X11)
|
|
119
|
+
- иначе -> терминал (input)
|
|
120
|
+
"""
|
|
121
|
+
platform = sys.platform
|
|
122
|
+
|
|
123
|
+
if sys.platform == 'darwin':
|
|
124
|
+
result = _ask_via_tkinter(prompt)
|
|
125
|
+
if result is not None:
|
|
126
|
+
return result
|
|
127
|
+
return _ask_via_terminal(prompt)
|
|
128
|
+
elif platform.startswith("linux"):
|
|
129
|
+
is_wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
|
|
130
|
+
is_xorg = bool(os.environ.get("DISPLAY"))
|
|
131
|
+
|
|
132
|
+
if is_wayland or is_xorg:
|
|
133
|
+
# если системного диалога нет — пробуем tkinter
|
|
134
|
+
result = _ask_via_tkinter(prompt)
|
|
135
|
+
if result is not None:
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
# zenity/kdialog умеют работать в обоих окружениях
|
|
139
|
+
result = _ask_via_linux_dialog(prompt)
|
|
140
|
+
if result is not None:
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
# нет графического окружения или диалоговых утилит
|
|
144
|
+
return _ask_via_terminal(prompt)
|
|
145
|
+
else:
|
|
146
|
+
result = _ask_via_tkinter(prompt)
|
|
147
|
+
if result is not None:
|
|
148
|
+
return result
|
|
149
|
+
return _ask_via_terminal(prompt)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import gzip
|
|
3
|
+
import shutil
|
|
4
|
+
import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from .model import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Rotator:
|
|
11
|
+
"""Ротация по размеру ИЛИ по времени. Что сработает раньше - то и ротирует."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *, size: int, at: datetime.time):
|
|
14
|
+
self._size_limit = size
|
|
15
|
+
now = datetime.datetime.now()
|
|
16
|
+
self._time_limit = now.replace(
|
|
17
|
+
hour=at.hour, minute=at.minute, second=at.second, microsecond=0
|
|
18
|
+
)
|
|
19
|
+
# Если сейчас уже позже целевого времени - цель на завтра,
|
|
20
|
+
# чтобы не ротировать сразу же при старте.
|
|
21
|
+
if now >= self._time_limit:
|
|
22
|
+
self._time_limit += datetime.timedelta(days=1)
|
|
23
|
+
|
|
24
|
+
def should_rotate(self, message, file) -> bool:
|
|
25
|
+
# Условие 1: размер
|
|
26
|
+
file.seek(0, 2)
|
|
27
|
+
|
|
28
|
+
if file.tell() + len(message) > self._size_limit:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
# Условие 2: время
|
|
32
|
+
excess = message.record['time'].timestamp() - self._time_limit.timestamp()
|
|
33
|
+
if excess >= 0:
|
|
34
|
+
elapsed_days = datetime.timedelta(seconds=excess).days
|
|
35
|
+
self._time_limit += datetime.timedelta(days=elapsed_days + 1)
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _compress_all_logs_except_active(log_dir: Path, active_name: str) -> None:
|
|
42
|
+
"""Сжимает ВСЕ .log в директории, кроме активного файла."""
|
|
43
|
+
for log_file in sorted(log_dir.glob('*.log')):
|
|
44
|
+
if log_file.name == active_name:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
gz_path = log_file.with_suffix('.log.gz')
|
|
48
|
+
|
|
49
|
+
# Если .gz уже есть - исходник удаляем, он лишний
|
|
50
|
+
if gz_path.exists():
|
|
51
|
+
log_file.unlink()
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with open(log_file, 'rb') as f_in, gzip.open(gz_path, 'wb') as f_out:
|
|
56
|
+
shutil.copyfileobj(f_in, f_out)
|
|
57
|
+
log_file.unlink()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
# Не валим сервер из-за одного битого файла
|
|
60
|
+
logger.error('Failed to compress [{}]: [{}]', log_file, e)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def initialize_logger(config: Config, prefix: str) -> None:
|
|
64
|
+
global logger
|
|
65
|
+
|
|
66
|
+
log_dir = config.log_dir / prefix
|
|
67
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
today = datetime.date.today().strftime('%Y-%m-%d')
|
|
70
|
+
active_name = f"{today}.log"
|
|
71
|
+
|
|
72
|
+
# Сначала сжимаем всё старое, потом открываем логгер.
|
|
73
|
+
# Порядок важен: если делать наоборот, Loguru займёт активный
|
|
74
|
+
# файл, и он попадёт в сжатие.
|
|
75
|
+
_compress_all_logs_except_active(log_dir, active_name)
|
|
76
|
+
|
|
77
|
+
logger.remove()
|
|
78
|
+
|
|
79
|
+
filepath = str(log_dir / '{time:YYYY-MM-DD}.log')
|
|
80
|
+
rotator = _Rotator(size=10 * 1024 * 1024, at=datetime.time(0, 0, 0))
|
|
81
|
+
|
|
82
|
+
logger.add(
|
|
83
|
+
filepath,
|
|
84
|
+
level=config.log_level,
|
|
85
|
+
encoding='utf-8',
|
|
86
|
+
rotation=rotator.should_rotate,
|
|
87
|
+
compression='gz'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
logger.add(
|
|
91
|
+
sys.stdout,
|
|
92
|
+
level=config.log_level,
|
|
93
|
+
colorize=True
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
logger.add(
|
|
97
|
+
sys.stderr,
|
|
98
|
+
level='ERROR',
|
|
99
|
+
colorize=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logger.info('Log level: [{}]',config.log_level)
|
|
103
|
+
logger.info('Log file is [{}]', filepath)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseConfig(BaseModel):
|
|
6
|
+
log_dir: Path
|
|
7
|
+
log_level: str
|
|
8
|
+
src: Path
|
|
9
|
+
dst: Path
|
|
10
|
+
no_confirm: bool
|
|
11
|
+
assume_yes: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MainConfig(BaseConfig):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Config = MainConfig
|