hh-applicant-tool 0.7.10__py3-none-any.whl → 1.4.12__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.
Files changed (76) hide show
  1. hh_applicant_tool/__init__.py +1 -0
  2. hh_applicant_tool/__main__.py +1 -1
  3. hh_applicant_tool/ai/base.py +2 -0
  4. hh_applicant_tool/ai/openai.py +25 -35
  5. hh_applicant_tool/api/__init__.py +4 -2
  6. hh_applicant_tool/api/client.py +65 -68
  7. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -6
  8. hh_applicant_tool/api/datatypes.py +293 -0
  9. hh_applicant_tool/api/errors.py +57 -7
  10. hh_applicant_tool/api/user_agent.py +17 -0
  11. hh_applicant_tool/main.py +234 -113
  12. hh_applicant_tool/operations/apply_similar.py +353 -371
  13. hh_applicant_tool/operations/authorize.py +313 -120
  14. hh_applicant_tool/operations/call_api.py +18 -8
  15. hh_applicant_tool/operations/check_proxy.py +30 -0
  16. hh_applicant_tool/operations/clear_negotiations.py +90 -82
  17. hh_applicant_tool/operations/config.py +119 -16
  18. hh_applicant_tool/operations/install.py +34 -0
  19. hh_applicant_tool/operations/list_resumes.py +23 -11
  20. hh_applicant_tool/operations/log.py +77 -0
  21. hh_applicant_tool/operations/migrate_db.py +65 -0
  22. hh_applicant_tool/operations/query.py +122 -0
  23. hh_applicant_tool/operations/refresh_token.py +14 -13
  24. hh_applicant_tool/operations/reply_employers.py +201 -180
  25. hh_applicant_tool/operations/settings.py +95 -0
  26. hh_applicant_tool/operations/uninstall.py +26 -0
  27. hh_applicant_tool/operations/update_resumes.py +23 -11
  28. hh_applicant_tool/operations/whoami.py +40 -7
  29. hh_applicant_tool/storage/__init__.py +8 -0
  30. hh_applicant_tool/storage/facade.py +24 -0
  31. hh_applicant_tool/storage/models/__init__.py +0 -0
  32. hh_applicant_tool/storage/models/base.py +169 -0
  33. hh_applicant_tool/storage/models/contacts.py +28 -0
  34. hh_applicant_tool/storage/models/employer.py +12 -0
  35. hh_applicant_tool/storage/models/negotiation.py +16 -0
  36. hh_applicant_tool/storage/models/resume.py +19 -0
  37. hh_applicant_tool/storage/models/setting.py +6 -0
  38. hh_applicant_tool/storage/models/vacancy.py +36 -0
  39. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  40. hh_applicant_tool/storage/queries/schema.sql +132 -0
  41. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  42. hh_applicant_tool/storage/repositories/base.py +230 -0
  43. hh_applicant_tool/storage/repositories/contacts.py +14 -0
  44. hh_applicant_tool/storage/repositories/employers.py +14 -0
  45. hh_applicant_tool/storage/repositories/errors.py +19 -0
  46. hh_applicant_tool/storage/repositories/negotiations.py +13 -0
  47. hh_applicant_tool/storage/repositories/resumes.py +9 -0
  48. hh_applicant_tool/storage/repositories/settings.py +35 -0
  49. hh_applicant_tool/storage/repositories/vacancies.py +9 -0
  50. hh_applicant_tool/storage/utils.py +40 -0
  51. hh_applicant_tool/utils/__init__.py +31 -0
  52. hh_applicant_tool/utils/attrdict.py +6 -0
  53. hh_applicant_tool/utils/binpack.py +167 -0
  54. hh_applicant_tool/utils/config.py +55 -0
  55. hh_applicant_tool/utils/date.py +19 -0
  56. hh_applicant_tool/utils/json.py +61 -0
  57. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  58. hh_applicant_tool/utils/log.py +147 -0
  59. hh_applicant_tool/utils/misc.py +12 -0
  60. hh_applicant_tool/utils/mixins.py +221 -0
  61. hh_applicant_tool/utils/string.py +27 -0
  62. hh_applicant_tool/utils/terminal.py +32 -0
  63. hh_applicant_tool-1.4.12.dist-info/METADATA +685 -0
  64. hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
  65. hh_applicant_tool/ai/blackbox.py +0 -55
  66. hh_applicant_tool/color_log.py +0 -47
  67. hh_applicant_tool/mixins.py +0 -13
  68. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  69. hh_applicant_tool/operations/get_employer_contacts.py +0 -348
  70. hh_applicant_tool/telemetry_client.py +0 -106
  71. hh_applicant_tool/types.py +0 -45
  72. hh_applicant_tool/utils.py +0 -119
  73. hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
  74. hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
  75. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
  76. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,61 @@
1
+ import datetime as dt
2
+ import json
3
+ from typing import Any
4
+
5
+ # class DateAwareJSONEncoder(json.JSONEncoder):
6
+ # def default(self, o):
7
+ # if isinstance(o, dt.datetime):
8
+ # return o.isoformat()
9
+
10
+ # return super().default(o)
11
+
12
+
13
+ # Костыль чтобы в key-value хранить даты
14
+ class DateAwareJSONEncoder(json.JSONEncoder):
15
+ def default(self, o):
16
+ if isinstance(o, dt.datetime):
17
+ return int(o.timestamp())
18
+
19
+ return super().default(o)
20
+
21
+
22
+ # def date_parser_hook(dct):
23
+ # for k, v in dct.items():
24
+ # if isinstance(v, str):
25
+ # try:
26
+ # dct[k] = dt.datetime.fromisoformat(v)
27
+ # except (ValueError, TypeError):
28
+ # pass
29
+ # return dct
30
+
31
+
32
+ # class DateAwareJSONDecoder(json.JSONDecoder):
33
+ # def __init__(self, *args, **kwargs):
34
+ # super().__init__(*args, object_hook=date_parser_hook, **kwargs)
35
+
36
+
37
+ def dumps(obj, *args: Any, **kwargs: Any) -> str:
38
+ kwargs.setdefault("cls", DateAwareJSONEncoder)
39
+ kwargs.setdefault("ensure_ascii", False)
40
+ return json.dumps(obj, *args, **kwargs)
41
+
42
+
43
+ def dump(fp, obj, *args: Any, **kwargs: Any) -> None:
44
+ kwargs.setdefault("cls", DateAwareJSONEncoder)
45
+ kwargs.setdefault("ensure_ascii", False)
46
+ json.dump(fp, obj, *args, **kwargs)
47
+
48
+
49
+ def loads(s, *args: Any, **kwargs: Any) -> Any:
50
+ # kwargs.setdefault("object_hook", date_parser_hook)
51
+ return json.loads(s, *args, **kwargs)
52
+
53
+
54
+ def load(fp, *args: Any, **kwargs: Any) -> Any:
55
+ # kwargs.setdefault("object_hook", date_parser_hook)
56
+ return json.load(fp, *args, **kwargs)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ d = {"created_at": dt.datetime.now()}
61
+ print(loads(dumps(d)))
@@ -1,11 +1,11 @@
1
- # Unused
2
1
  """Парсер JSON с комментариями"""
3
2
 
4
- import re
3
+ import ast
5
4
  import enum
5
+ import re
6
6
  from dataclasses import dataclass
7
- import ast
8
7
  from typing import Any, Iterator
8
+
9
9
  # from collections import OrderedDict
10
10
 
11
11
 
@@ -42,7 +42,8 @@ def tokenize(s: str) -> Iterator[Token]:
42
42
  class JSONCParser:
43
43
  def parse(self, s: str) -> Any:
44
44
  self.token_it = filter(
45
- lambda t: t.token_type not in [TokenType.COMMENT, TokenType.WHITESPACE],
45
+ lambda t: t.token_type
46
+ not in [TokenType.COMMENT, TokenType.WHITESPACE],
46
47
  tokenize(s),
47
48
  )
48
49
  self.token: Token
@@ -90,7 +91,9 @@ class JSONCParser:
90
91
  num = self.token.value
91
92
  return float(num) if "." in num else int(num)
92
93
  elif self.match(TokenType.KEYWORD):
93
- return {"null": None, "true": True, "false": False}[self.token.value]
94
+ return {"null": None, "true": True, "false": False}[
95
+ self.token.value
96
+ ]
94
97
  else:
95
98
  raise SyntaxError(f"Unexpected token: {self.token.token_type.name}")
96
99
 
@@ -103,7 +106,10 @@ class JSONCParser:
103
106
  # print(f"{self.token =}, {self.next_token =}")
104
107
 
105
108
  def match(self, token_type: TokenType) -> bool:
106
- if self.next_token is not None and self.next_token.token_type == token_type:
109
+ if (
110
+ self.next_token is not None
111
+ and self.next_token.token_type == token_type
112
+ ):
107
113
  self.advance()
108
114
  return True
109
115
  return False
@@ -0,0 +1,147 @@
1
+ import enum
2
+ import logging
3
+ import re
4
+ from collections import deque
5
+ from datetime import datetime
6
+ from enum import auto
7
+ from logging.handlers import RotatingFileHandler
8
+ from os import PathLike
9
+ from typing import Callable, TextIO
10
+
11
+ # 10MB
12
+ MAX_LOG_SIZE = 10 << 20
13
+
14
+
15
+ class Color(enum.Enum):
16
+ BLACK = 30
17
+ RED = auto()
18
+ GREEN = auto()
19
+ YELLOW = auto()
20
+ BLUE = auto()
21
+ PURPLE = auto()
22
+ CYAN = auto()
23
+ WHITE = auto()
24
+
25
+ def __str__(self) -> str:
26
+ return str(self.value)
27
+
28
+
29
+ class ColorHandler(logging.StreamHandler):
30
+ _color_map = {
31
+ "CRITICAL": Color.RED,
32
+ "ERROR": Color.RED,
33
+ "WARNING": Color.RED,
34
+ "INFO": Color.GREEN,
35
+ "DEBUG": Color.BLUE,
36
+ }
37
+
38
+ def format(self, record: logging.LogRecord) -> str:
39
+ # Подавляем вывод подробного сообщения об ошибке
40
+ orig_exc_info = record.exc_info
41
+
42
+ # Детали ошибки показываем только при отладке
43
+ if self.level > logging.DEBUG:
44
+ record.exc_info = None
45
+
46
+ message = super().format(record)
47
+ # Обязательно нужно восстановить оригинальное значение или в файловом
48
+ # логе не будет деталей ошибки
49
+ record.exc_info = orig_exc_info
50
+ # isatty = getattr(self.stream, "isatty", None)
51
+ # if isatty and isatty():
52
+ color_code = self._color_map[record.levelname]
53
+ return f"\033[{color_code}m{message}\033[0m"
54
+ # return message
55
+
56
+
57
+ class RedactingFilter(logging.Filter):
58
+ def __init__(
59
+ self,
60
+ patterns: list[str],
61
+ # По умолчанию количество звездочек равно оригинальной строке
62
+ placeholder: str | Callable = lambda m: "*" * len(m.group(0)),
63
+ ):
64
+ super().__init__()
65
+ self.pattern = (
66
+ re.compile(f"({'|'.join(patterns)})") if patterns else None
67
+ )
68
+ self.placeholder = placeholder
69
+
70
+ def filter(self, record: logging.LogRecord) -> bool:
71
+ if self.pattern:
72
+ msg = record.getMessage()
73
+ msg = self.pattern.sub(self.placeholder, msg)
74
+ record.msg, record.args = msg, ()
75
+
76
+ return True
77
+
78
+
79
+ def setup_logger(
80
+ logger: logging.Logger,
81
+ verbosity_level: int,
82
+ log_file: PathLike,
83
+ ) -> None:
84
+ # В лог-файл пишем все!
85
+ logger.setLevel(logging.DEBUG)
86
+ color_handler = ColorHandler()
87
+ # [C] Critical Error Occurred
88
+ color_handler.setFormatter(
89
+ logging.Formatter("[%(levelname).1s] %(message)s")
90
+ )
91
+ color_handler.setLevel(verbosity_level)
92
+
93
+ # Логи
94
+ file_handler = RotatingFileHandler(
95
+ log_file,
96
+ maxBytes=MAX_LOG_SIZE,
97
+ # backupCount=1,
98
+ encoding="utf-8",
99
+ )
100
+ file_handler.setFormatter(
101
+ logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
102
+ )
103
+ file_handler.setLevel(logging.DEBUG)
104
+
105
+ redactor = RedactingFilter(
106
+ [
107
+ r"\b[A-Z0-9]{64,}\b",
108
+ r"\b[a-fA-F0-9]{32,}\b", # request_id, resume_id
109
+ ]
110
+ )
111
+
112
+ file_handler.addFilter(redactor)
113
+
114
+ for h in [color_handler, file_handler]:
115
+ logger.addHandler(h)
116
+
117
+
118
+ TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
119
+
120
+
121
+ def collect_traceback_logs(
122
+ fp: TextIO,
123
+ after_dt: datetime,
124
+ maxlen: int = 1000,
125
+ ) -> str:
126
+ error_lines = deque(maxlen=maxlen)
127
+ prev_line = ""
128
+ log_dt = None
129
+ collecting_traceback = False
130
+ for line in fp:
131
+ if ts_match := TS_RE.match(line):
132
+ log_dt = datetime.strptime(ts_match.group(0), "%Y-%m-%d %H:%M:%S")
133
+ collecting_traceback = False
134
+
135
+ if (
136
+ line.startswith("Traceback (most recent call last):")
137
+ and log_dt
138
+ and log_dt >= after_dt
139
+ ):
140
+ error_lines.append(prev_line)
141
+ collecting_traceback = True
142
+
143
+ if collecting_traceback:
144
+ error_lines.append(line)
145
+
146
+ prev_line = line
147
+ return "".join(error_lines)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import sys
5
+ from functools import partial
6
+
7
+
8
+ def calc_hash(data: str) -> str:
9
+ return hashlib.sha256(data.encode()).hexdigest()
10
+
11
+
12
+ print_err = partial(print, file=sys.stderr, flush=True)
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import socket
5
+ from datetime import datetime, timedelta, timezone
6
+ from functools import cache
7
+ from importlib.metadata import version
8
+ from logging import getLogger
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ import requests
12
+ from requests.exceptions import RequestException
13
+
14
+ from ..ai.openai import ChatOpenAI
15
+ from . import binpack
16
+ from .log import collect_traceback_logs
17
+
18
+ if TYPE_CHECKING:
19
+ from ..main import HHApplicantTool
20
+
21
+ log = getLogger(__package__)
22
+
23
+
24
+ def parse_version(v: str) -> tuple[int, int, int]:
25
+ return tuple(map(int, v.split(".")))
26
+
27
+
28
+ @cache
29
+ def get_package_version() -> str | None:
30
+ return version("hh-applicant-tool")
31
+
32
+
33
+ class ErrorReporter:
34
+ def __build_report(
35
+ self: HHApplicantTool,
36
+ last_report: datetime,
37
+ ) -> dict:
38
+ error_logs = ""
39
+ if self.log_file.exists():
40
+ with self.log_file.open(encoding="utf-8", errors="ignore") as fp:
41
+ error_logs = collect_traceback_logs(
42
+ fp, last_report, maxlen=10000
43
+ )
44
+
45
+ # Эти данные нужны для воспроизведения ошибок. Среди них ваших
46
+ # персональных данных нет
47
+ vacancy_contacts = [
48
+ c.to_dict()
49
+ for c in self.storage.vacancy_contacts.find(
50
+ updated_at__ge=last_report
51
+ )
52
+ ][-10000:]
53
+
54
+ for c in vacancy_contacts:
55
+ c.pop("id", 0)
56
+
57
+ employers = [
58
+ {
59
+ k: v
60
+ for k, v in emp.to_dict().items()
61
+ if k
62
+ in [
63
+ "id",
64
+ "type",
65
+ "alternate_url",
66
+ "area_id",
67
+ "area_name",
68
+ "name",
69
+ "site_url",
70
+ "created_at",
71
+ ]
72
+ }
73
+ for emp in self.storage.employers.find(updated_at__ge=last_report)
74
+ ][-10000:]
75
+
76
+ vacancies = [
77
+ {
78
+ k: v
79
+ for k, v in vac.to_dict().items()
80
+ if k
81
+ in [
82
+ "id",
83
+ "alternate_url",
84
+ "area_id",
85
+ "area_name",
86
+ "salary_from",
87
+ "salary_to",
88
+ "currency",
89
+ "name",
90
+ "professional_roles",
91
+ "experience",
92
+ "remote",
93
+ "created_at",
94
+ ]
95
+ }
96
+ for vac in self.storage.vacancies.find(updated_at__ge=last_report)
97
+ ][-10000:]
98
+
99
+ # log.info("num vacncies: %d", len(vacancies))
100
+
101
+ system_info = {
102
+ "os": platform.system(),
103
+ "os_release": platform.release(),
104
+ "hostname": socket.gethostname(),
105
+ "python_version": platform.python_version(),
106
+ }
107
+
108
+ return dict(
109
+ error_logs=error_logs,
110
+ vacancy_contacts=vacancy_contacts,
111
+ employers=employers,
112
+ vacancies=vacancies,
113
+ package_version=get_package_version(),
114
+ system_info=system_info,
115
+ report_created=datetime.now(timezone.utc),
116
+ )
117
+
118
+ def __send_report(self: HHApplicantTool, data: bytes) -> int:
119
+ try:
120
+ r = self.session.post(
121
+ # "http://localhost:8000/report",
122
+ "https://hh-applicant-tool.mooo.com:54157/report",
123
+ data=data,
124
+ timeout=15.0,
125
+ )
126
+ r.raise_for_status()
127
+ return r.status_code == 200
128
+ except RequestException:
129
+ # log.error("Network error: %s", e)
130
+ return False
131
+
132
+ def _process_reporting(self):
133
+ # Получаем timestamp последнего репорта
134
+ last_report = datetime.fromtimestamp(
135
+ self.storage.settings.get_value("_last_report", 0)
136
+ )
137
+
138
+ if datetime.now() >= last_report + timedelta(hours=72):
139
+ try:
140
+ report_dict = self.__build_report(last_report)
141
+ has_data = any(
142
+ [
143
+ report_dict.get("error_logs"),
144
+ report_dict.get("employers"),
145
+ report_dict.get("vacancy_contacts"),
146
+ report_dict.get("vacancies"),
147
+ ]
148
+ )
149
+ if has_data:
150
+ data = binpack.serialize(report_dict)
151
+ log.debug("Report body size: %d", len(data))
152
+ # print(binpack.deserialize(data))
153
+ if self.__send_report(data):
154
+ log.debug("Report was sent")
155
+ else:
156
+ log.debug("Report failed")
157
+ else:
158
+ log.debug("Nothing to report")
159
+ finally:
160
+ # Сохраняем время последней попытки/удачного репорта
161
+ self.storage.settings.set_value("_last_report", datetime.now())
162
+
163
+
164
+ class VersionChecker:
165
+ def __get_latest_version(self: HHApplicantTool) -> Literal[False] | str:
166
+ try:
167
+ response = self.session.get(
168
+ "https://pypi.org/pypi/hh-applicant-tool/json", timeout=15
169
+ )
170
+ ver = response.json().get("info", {}).get("version")
171
+ # log.debug(ver)
172
+ return ver
173
+ except requests.RequestException:
174
+ return False
175
+
176
+ def _check_version(self: HHApplicantTool) -> bool:
177
+ if datetime.now().timestamp() >= self.storage.settings.get_value(
178
+ "_next_version_check", 0
179
+ ):
180
+ if v := self.__get_latest_version():
181
+ self.storage.settings.set_value("_latest_version", v)
182
+ self.storage.settings.set_value(
183
+ "_next_version_check", datetime.now() + timedelta(hours=1)
184
+ )
185
+
186
+ if (
187
+ latest_ver := self.storage.settings.get_value("_latest_version")
188
+ ) and (cur_ver := get_package_version()):
189
+ if parse_version(latest_ver) > parse_version(cur_ver):
190
+ log.warning(
191
+ "ТЕКУЩАЯ ВЕРСИЯ %s УСТАРЕЛА. РЕКОМЕНДУЕТСЯ ОБНОВИТЬ ЕЁ ДО ВЕРСИИ %s.",
192
+ cur_ver,
193
+ latest_ver,
194
+ )
195
+
196
+
197
+ class ChatOpenAISupport:
198
+ def get_openai_chat(
199
+ self: HHApplicantTool,
200
+ system_prompt: str,
201
+ ) -> ChatOpenAI:
202
+ c = self.config.get("openai", {})
203
+ if not (token := c.get("token")):
204
+ raise ValueError("Токен для OpenAI не задан")
205
+ return ChatOpenAI(
206
+ token=token,
207
+ model=c.get("model", "gpt-5.1"),
208
+ system_prompt=system_prompt,
209
+ session=self.session,
210
+ )
211
+
212
+
213
+ class MegaTool(ErrorReporter, VersionChecker, ChatOpenAISupport):
214
+ def _check_system(self: HHApplicantTool):
215
+ if not self.storage.settings.get_value("disable_version_check", False):
216
+ self._check_version()
217
+
218
+ if self.storage.settings.get_value("send_error_reports", True):
219
+ self._process_reporting()
220
+ else:
221
+ log.warning("ОТКЛЮЧЕНА ОТПРАВКА СООБЩЕНИЙ ОБ ОШИБКАХ!")
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import re
5
+ from typing import Any
6
+
7
+ def shorten(s: str, limit: int = 75, ellipsis: str = "…") -> str:
8
+ return s[:limit] + bool(s[limit:]) * ellipsis
9
+
10
+ def rand_text(s: str) -> str:
11
+ while (
12
+ temp := re.sub(
13
+ r"{([^{}]+)}",
14
+ lambda m: random.choice(
15
+ m.group(1).split("|"),
16
+ ),
17
+ s,
18
+ )
19
+ ) != s:
20
+ s = temp
21
+ return s
22
+
23
+ def bool2str(v: bool) -> str:
24
+ return str(v).lower()
25
+
26
+ def list2str(items: list[Any] | None) -> str:
27
+ return ",".join(f"{v}" for v in items) if items else ""
@@ -0,0 +1,32 @@
1
+ import base64
2
+ import ctypes
3
+ import platform
4
+ import sys
5
+
6
+
7
+ def setup_terminal() -> None:
8
+ if platform.system() != "Windows":
9
+ return
10
+ try:
11
+ kernel32 = ctypes.windll.kernel32
12
+ # -11 = STD_OUTPUT_HANDLE
13
+ handle = kernel32.GetStdHandle(-11)
14
+ mode = ctypes.c_uint()
15
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
16
+ # 0x0004 = ENABLE_VIRTUAL_TERMINAL_PROCESSING
17
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
18
+ except Exception:
19
+ # Если что-то пошло не так (старая Windows или нет прав),
20
+ # просто продолжаем работу без цветов
21
+ pass
22
+
23
+
24
+ def print_kitty_image(data: bytes) -> None:
25
+ # Кодируем весь файл целиком (он уже сжат в PNG)
26
+ b64data = base64.b64encode(data).decode("ascii")
27
+
28
+ # f=100 говорит терминалу: "это PNG, разберись сам с размерами"
29
+ # Нам больше не нужно указывать s=... и v=...
30
+ sys.stdout.write(f"\033_Ga=T,f=100;{b64data}\033\\")
31
+ sys.stdout.flush()
32
+ print()