hh-applicant-tool 1.4.12__py3-none-any.whl → 1.5.7__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.
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
 
3
- from . import main
3
+ from .main import main
4
4
 
5
5
  if __name__ == "__main__":
6
6
  sys.exit(main())
@@ -0,0 +1 @@
1
+ from .openai import ChatOpenAI, OpenAIError
@@ -1,6 +1,5 @@
1
1
  import logging
2
- from dataclasses import dataclass, field
3
- from typing import ClassVar
2
+ from dataclasses import KW_ONLY, dataclass, field
4
3
 
5
4
  import requests
6
5
 
@@ -9,43 +8,60 @@ from .base import AIError
9
8
  logger = logging.getLogger(__package__)
10
9
 
11
10
 
11
+ DEFAULT_COMPLETION_ENDPOINT = "https://api.openai.com/v1/chat/completions"
12
+
13
+
12
14
  class OpenAIError(AIError):
13
15
  pass
14
16
 
15
17
 
16
18
  @dataclass
17
19
  class ChatOpenAI:
18
- chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
19
-
20
20
  token: str
21
- model: str
21
+ _: KW_ONLY
22
22
  system_prompt: str | None = None
23
+ timeout: float = 15.0
23
24
  temperature: float = 0.7
24
25
  max_completion_tokens: int = 1000
26
+ model: str | None = None
27
+ completion_endpoint: str = None
25
28
  session: requests.Session = field(default_factory=requests.Session)
26
29
 
30
+ def __post_init__(self) -> None:
31
+ self.completion_endpoint = (
32
+ self.completion_endpoint or DEFAULT_COMPLETION_ENDPOINT
33
+ )
34
+
27
35
  def _default_headers(self) -> dict[str, str]:
28
36
  return {
29
37
  "Authorization": f"Bearer {self.token}",
30
38
  }
31
39
 
32
40
  def send_message(self, message: str) -> str:
41
+ messages = []
42
+
43
+ # Добавляем системный промпт только если он не пустой и не None
44
+ if self.system_prompt:
45
+ messages.append({"role": "system", "content": self.system_prompt})
46
+
47
+ # Пользовательское сообщение всегда обязательно
48
+ messages.append({"role": "user", "content": message})
49
+
33
50
  payload = {
34
- "model": self.model,
35
- "messages": [
36
- {"role": "system", "content": self.system_prompt},
37
- {"role": "user", "content": message},
38
- ],
51
+ "messages": messages,
39
52
  "temperature": self.temperature,
40
53
  "max_completion_tokens": self.max_completion_tokens,
41
54
  }
42
55
 
56
+ if self.model:
57
+ payload["model"] = self.model
58
+
43
59
  try:
44
60
  response = self.session.post(
45
- self.chat_endpoint,
61
+ self.completion_endpoint,
46
62
  json=payload,
47
63
  headers=self._default_headers(),
48
- timeout=30,
64
+ timeout=self.timeout,
49
65
  )
50
66
  response.raise_for_status()
51
67
 
@@ -13,19 +13,20 @@ from urllib.parse import urlencode, urljoin
13
13
  import requests
14
14
  from requests import Session
15
15
 
16
+ from hh_applicant_tool.api.user_agent import generate_android_useragent
17
+
16
18
  from . import errors
17
19
  from .client_keys import (
18
20
  ANDROID_CLIENT_ID,
19
21
  ANDROID_CLIENT_SECRET,
20
22
  )
21
23
  from .datatypes import AccessToken
22
- from .user_agent import generate_android_useragent
23
24
 
24
25
  __all__ = ("ApiClient", "OAuthClient")
25
26
 
26
27
  HH_API_URL = "https://api.hh.ru/"
27
28
  HH_OAUTH_URL = "https://hh.ru/oauth/"
28
- DEFAULT_DELAY = 0.334
29
+ DEFAULT_DELAY = 0.345
29
30
 
30
31
  AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
31
32
  T = TypeVar("T")
@@ -41,18 +42,21 @@ class BaseClient:
41
42
  _: dataclasses.KW_ONLY
42
43
  user_agent: str | None = None
43
44
  session: Session | None = None
44
- delay: float = DEFAULT_DELAY
45
+ delay: float | None = None
45
46
  _previous_request_time: float = 0.0
46
47
 
47
48
  def __post_init__(self) -> None:
48
49
  assert self.base_url.endswith("/"), "base_url must ends with /"
49
- self.lock = Lock()
50
+ self.delay = self.delay or DEFAULT_DELAY
51
+ self.user_agent = self.user_agent or generate_android_useragent()
52
+
50
53
  # logger.debug(f"user agent: {self.user_agent}")
54
+
51
55
  if not self.session:
52
56
  logger.debug("create new session")
53
57
  self.session = requests.session()
54
- # if self.proxies:
55
- # logger.debug(f"client proxies: {self.proxies}")
58
+
59
+ self.lock = Lock()
56
60
 
57
61
  @property
58
62
  def proxies(self):
@@ -60,7 +64,7 @@ class BaseClient:
60
64
 
61
65
  def _default_headers(self) -> dict[str, str]:
62
66
  return {
63
- "user-agent": self.user_agent or generate_android_useragent(),
67
+ "user-agent": self.user_agent,
64
68
  "x-hh-app-active": "true",
65
69
  }
66
70
 
hh_applicant_tool/main.py CHANGED
@@ -17,8 +17,7 @@ from typing import Any, Iterable
17
17
  import requests
18
18
  import urllib3
19
19
 
20
- from . import utils
21
- from .api import ApiClient, datatypes
20
+ from . import ai, api, utils
22
21
  from .storage import StorageFacade
23
22
  from .utils.log import setup_logger
24
23
  from .utils.mixins import MegaTool
@@ -29,7 +28,6 @@ DEFAULT_CONFIG_DIR = utils.get_config_path() / (__package__ or "").replace(
29
28
  DEFAULT_CONFIG_FILENAME = "config.json"
30
29
  DEFAULT_LOG_FILENAME = "log.txt"
31
30
  DEFAULT_DATABASE_FILENAME = "data"
32
- DEFAULT_PROFILE_ID = "."
33
31
 
34
32
  logger = logging.getLogger(__package__)
35
33
 
@@ -54,7 +52,6 @@ class BaseNamespace(argparse.Namespace):
54
52
  delay: float
55
53
  user_agent: str
56
54
  proxy_url: str
57
- disable_telemetry: bool
58
55
 
59
56
 
60
57
  class HHApplicantTool(MegaTool):
@@ -89,19 +86,18 @@ class HHApplicantTool(MegaTool):
89
86
  "--config",
90
87
  help="Путь до директории с конфигом",
91
88
  type=Path,
92
- default=DEFAULT_CONFIG_DIR,
89
+ default=None,
93
90
  )
94
91
  parser.add_argument(
95
92
  "--profile-id",
96
93
  "--profile",
97
- help="Используемый профиль — подкаталог в --config-dir",
98
- default=DEFAULT_PROFILE_ID,
94
+ help="Используемый профиль — подкаталог в --config-dir. Так же можно передать через переменную окружения HH_PROFILE_ID.",
99
95
  )
100
96
  parser.add_argument(
101
97
  "-d",
98
+ "--api-delay",
102
99
  "--delay",
103
100
  type=float,
104
- default=0.654,
105
101
  help="Задержка между запросами к API HH по умолчанию",
106
102
  )
107
103
  parser.add_argument(
@@ -168,14 +164,20 @@ class HHApplicantTool(MegaTool):
168
164
  session.verify = False
169
165
 
170
166
  if proxies := self._get_proxies():
171
- logger.info("Use proxies: %r", proxies)
167
+ logger.info("Use proxies for requests: %r", proxies)
172
168
  session.proxies = proxies
173
169
 
174
170
  return session
175
171
 
176
- @property
172
+ @cached_property
177
173
  def config_path(self) -> Path:
178
- return (self.args.config_dir / self.args.profile_id).resolve()
174
+ return (
175
+ (
176
+ self.args.config_dir
177
+ or Path(getenv("CONFIG_DIR", DEFAULT_CONFIG_DIR))
178
+ )
179
+ / (self.args.profile_id or getenv("HH_PROFILE_ID", "."))
180
+ ).resolve()
179
181
 
180
182
  @cached_property
181
183
  def config(self) -> utils.Config:
@@ -199,36 +201,35 @@ class HHApplicantTool(MegaTool):
199
201
  return StorageFacade(self.db)
200
202
 
201
203
  @cached_property
202
- def api_client(self) -> ApiClient:
204
+ def api_client(self) -> api.client.ApiClient:
203
205
  args = self.args
204
206
  config = self.config
205
207
  token = config.get("token", {})
206
- api = ApiClient(
208
+ return api.client.ApiClient(
207
209
  client_id=config.get("client_id"),
208
210
  client_secret=config.get("client_id"),
209
211
  access_token=token.get("access_token"),
210
212
  refresh_token=token.get("refresh_token"),
211
213
  access_expires_at=token.get("access_expires_at"),
212
- delay=args.delay,
213
- user_agent=config.get("user_agent"),
214
+ delay=args.api_delay or config.get("api_delay"),
215
+ user_agent=args.user_agent or config.get("user_agent"),
214
216
  session=self.session,
215
217
  )
216
- return api
217
218
 
218
- def get_me(self) -> datatypes.User:
219
+ def get_me(self) -> api.datatypes.User:
219
220
  return self.api_client.get("/me")
220
221
 
221
- def get_resumes(self) -> list[datatypes.Resume]:
222
+ def get_resumes(self) -> list[api.datatypes.Resume]:
222
223
  return self.api_client.get("/resumes/mine")["items"]
223
224
 
224
225
  def first_resume_id(self) -> str:
225
- resumes = self.get_resumes()
226
- return resumes[0]["id"]
226
+ resume = self.get_resumes()[0]
227
+ return resume["id"]
227
228
 
228
229
  def get_blacklisted(self) -> list[str]:
229
230
  rv = []
230
231
  for page in count():
231
- r: datatypes.PaginatedItems[datatypes.EmployerShort] = (
232
+ r: api.datatypes.PaginatedItems[api.datatypes.EmployerShort] = (
232
233
  self.api_client.get("/employers/blacklisted", page=page)
233
234
  )
234
235
  rv += [item["id"] for item in r["items"]]
@@ -238,7 +239,7 @@ class HHApplicantTool(MegaTool):
238
239
 
239
240
  def get_negotiations(
240
241
  self, status: str = "active"
241
- ) -> Iterable[datatypes.Negotiation]:
242
+ ) -> Iterable[api.datatypes.Negotiation]:
242
243
  for page in count():
243
244
  r: dict[str, Any] = self.api_client.get(
244
245
  "/negotiations",
@@ -257,6 +258,8 @@ class HHApplicantTool(MegaTool):
257
258
  if page + 1 >= r.get("pages", 0):
258
259
  break
259
260
 
261
+ # TODO: добавить еще методов или те удалить?
262
+
260
263
  def save_token(self) -> bool:
261
264
  if self.api_client.access_token != self.config.get("token", {}).get(
262
265
  "access_token"
@@ -265,6 +268,20 @@ class HHApplicantTool(MegaTool):
265
268
  return True
266
269
  return False
267
270
 
271
+ def get_openai_chat(self, system_prompt: str) -> ai.ChatOpenAI:
272
+ c = self.config.get("openai", {})
273
+ if not (token := c.get("token")):
274
+ raise ValueError("Токен для OpenAI не задан")
275
+ return ai.ChatOpenAI(
276
+ token=token,
277
+ model=c.get("model"),
278
+ temperature=c.get("temperature", 0.7),
279
+ max_completion_tokens=c.get("max_completion_tokens", 1000),
280
+ system_prompt=system_prompt,
281
+ completion_endpoint=c.get("completion_endpoint"),
282
+ session=self.session,
283
+ )
284
+
268
285
  def run(self) -> None | int:
269
286
  verbosity_level = max(
270
287
  logging.DEBUG,
@@ -273,19 +290,25 @@ class HHApplicantTool(MegaTool):
273
290
 
274
291
  setup_logger(logger, verbosity_level, self.log_file)
275
292
 
276
- if sys.platform == "win32":
277
- utils.setup_terminal()
293
+ logger.debug("Путь до профиля: %s", self.config_path)
294
+
295
+ utils.setup_terminal()
278
296
 
279
297
  try:
280
298
  if self.args.run:
281
299
  try:
282
- res = self.args.run(self)
283
- if self.save_token():
284
- logger.info("Токен был обновлен.")
285
- return res
300
+ return self.args.run(self)
286
301
  except KeyboardInterrupt:
287
302
  logger.warning("Выполнение прервано пользователем!")
288
- return 1
303
+ except api.errors.CaptchaRequired as ex:
304
+ logger.error(f"Требуется ввод капчи: {ex.captcha_url}")
305
+ except api.errors.InternalServerError:
306
+ logger.error(
307
+ "Сервер HH.RU не смог обработать запрос из-за высокой"
308
+ " нагрузки или по иной причине"
309
+ )
310
+ except api.errors.Forbidden:
311
+ logger.error("Требуется авторизация")
289
312
  except sqlite3.Error as ex:
290
313
  logger.exception(ex)
291
314
 
@@ -295,10 +318,13 @@ class HHApplicantTool(MegaTool):
295
318
  f"Возможно база данных повреждена, попробуйте выполнить команду:\n\n" # noqa: E501
296
319
  f" {script_name} migrate-db"
297
320
  )
298
- return 1
299
321
  except Exception as e:
300
322
  logger.exception(e)
301
- return 1
323
+ finally:
324
+ # Токен мог автоматически обновиться
325
+ if self.save_token():
326
+ logger.info("Токен был сохранен после обновления.")
327
+ return 1
302
328
  self._parser.print_help(file=sys.stderr)
303
329
  return 2
304
330
  finally:
@@ -12,7 +12,13 @@ from ..api.datatypes import PaginatedItems, SearchVacancy
12
12
  from ..api.errors import ApiError, LimitExceeded
13
13
  from ..main import BaseNamespace, BaseOperation
14
14
  from ..storage.repositories.errors import RepositoryError
15
- from ..utils import bool2str, list2str, rand_text, shorten
15
+ from ..utils.string import (
16
+ bool2str,
17
+ list2str,
18
+ rand_text,
19
+ shorten,
20
+ unescape_string,
21
+ )
16
22
 
17
23
  if TYPE_CHECKING:
18
24
  from ..main import HHApplicantTool
@@ -77,7 +83,7 @@ class Operation(BaseOperation):
77
83
  "-L",
78
84
  "--message-list-path",
79
85
  "--message-list",
80
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
86
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. Символы \\n будут заменены на переносы.", # noqa: E501
81
87
  type=Path,
82
88
  )
83
89
  parser.add_argument(
@@ -412,7 +418,7 @@ class Operation(BaseOperation):
412
418
  resume["alternate_url"],
413
419
  )
414
420
  print(
415
- "⛔ Пришел отказ от",
421
+ "⛔ Пришел отказ от",
416
422
  vacancy["alternate_url"],
417
423
  "на резюме",
418
424
  resume["alternate_url"],
@@ -440,7 +446,7 @@ class Operation(BaseOperation):
440
446
  logger.debug("prompt: %s", msg)
441
447
  msg = self.openai_chat.send_message(msg)
442
448
  else:
443
- msg = (
449
+ msg = unescape_string(
444
450
  rand_text(random.choice(self.application_messages))
445
451
  % message_placeholders
446
452
  )
@@ -487,9 +493,9 @@ class Operation(BaseOperation):
487
493
  params = {
488
494
  "page": page,
489
495
  "per_page": self.per_page,
490
- "order_by": self.order_by,
491
496
  }
492
-
497
+ if self.order_by:
498
+ params |= {"order_by": self.order_by}
493
499
  if self.search:
494
500
  params["text"] = self.search
495
501
  if self.schedule:
@@ -14,7 +14,7 @@ except ImportError:
14
14
  pass
15
15
 
16
16
  from ..main import BaseOperation
17
- from ..utils.terminal import print_kitty_image
17
+ from ..utils.terminal import print_kitty_image, print_sixel_mage
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from ..main import HHApplicantTool
@@ -91,7 +91,14 @@ class Operation(BaseOperation):
91
91
  "--use-kitty",
92
92
  "--kitty",
93
93
  action="store_true",
94
- help="Использовать kitty protocol для вывода изображения в терминал. Гуглите поддерживает ли ваш терминал его",
94
+ help="Использовать kitty protocol для вывода капчи в терминал.",
95
+ )
96
+ parser.add_argument(
97
+ "-s",
98
+ "--use-sixel",
99
+ "--sixel",
100
+ action="store_true",
101
+ help="Использовать sixel protocol для вывода капчи в терминал.",
95
102
  )
96
103
 
97
104
  def run(self, tool: HHApplicantTool) -> None:
@@ -152,7 +159,7 @@ class Operation(BaseOperation):
152
159
 
153
160
  # # Блокировка сканирования локальных портов
154
161
  # if any(d in url for d in ["localhost", "127.0.0.1", "::1"]):
155
- # logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
162
+ # logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
156
163
  # return await route.abort()
157
164
 
158
165
  # # Оптимизация трафика в headless
@@ -201,8 +208,11 @@ class Operation(BaseOperation):
201
208
  await page.fill(self.SELECT_LOGIN_INPUT, username)
202
209
  logger.debug("Логин введен")
203
210
 
204
- if args.password:
205
- await self._direct_login(page, args.password)
211
+ password = args.password or storage.settings.get_value(
212
+ "auth.password"
213
+ )
214
+ if password:
215
+ await self._direct_login(page, password)
206
216
  else:
207
217
  await self._onetime_code_login(page)
208
218
 
@@ -302,14 +312,11 @@ class Operation(BaseOperation):
302
312
  logger.debug("Капчи нет, продолжаем как обычно.")
303
313
  return
304
314
 
305
- if not self._args.use_kitty:
315
+ if not (self._args.use_kitty or self._args.use_sixel):
306
316
  raise RuntimeError(
307
- "Используйте флаг --use-kitty/-k для вывода капчи в терминал."
308
- "Работает не во всех терминалах!",
317
+ "Требуется ввод капчи!",
309
318
  )
310
319
 
311
- logger.info("Обнаружена капча!")
312
-
313
320
  # box = await captcha_element.bounding_box()
314
321
 
315
322
  # width = int(box["width"])
@@ -319,10 +326,15 @@ class Operation(BaseOperation):
319
326
 
320
327
  print(
321
328
  "Если вы не видите картинку ниже, то ваш терминал не поддерживает"
322
- " kitty protocol."
329
+ " вывод изображений."
323
330
  )
324
331
  print()
325
- print_kitty_image(img_bytes)
332
+
333
+ if self._args.use_kitty:
334
+ print_kitty_image(img_bytes)
335
+
336
+ if self._args.use_sixel:
337
+ print_sixel_mage(img_bytes)
326
338
 
327
339
  captcha_text = (await ainput("Введите текст с картинки: ")).strip()
328
340
 
@@ -55,7 +55,6 @@ class Operation(BaseOperation):
55
55
 
56
56
  def clear(self) -> None:
57
57
  blacklisted = set(self.tool.get_blacklisted())
58
- storage = self.tool.storage
59
58
  for negotiation in self.tool.get_negotiations():
60
59
  vacancy = negotiation["vacancy"]
61
60
 
@@ -68,7 +68,7 @@ class Operation(BaseOperation):
68
68
  writer.writerows(cursor.fetchall())
69
69
 
70
70
  if tool.args.output:
71
- print(f"✅ Exported to {tool.args.output.name}")
71
+ print(f"✅ Exported to {tool.args.output.name}")
72
72
  return
73
73
 
74
74
  rows = cursor.fetchmany(MAX_RESULTS + 1)
@@ -93,7 +93,7 @@ class Operation(BaseOperation):
93
93
  print(f"Rows affected: {cursor.rowcount}")
94
94
 
95
95
  except sqlite3.Error as ex:
96
- print(f"❌ SQL Error: {ex}")
96
+ print(f"❌ SQL Error: {ex}")
97
97
  return 1
98
98
 
99
99
  if initial_sql := tool.args.sql:
@@ -26,5 +26,12 @@ class Operation(BaseOperation):
26
26
  pass
27
27
 
28
28
  def run(self, tool: HHApplicantTool) -> None:
29
- tool.api_client.refresh_access_token()
30
- print("✅ Токен обновлен!")
29
+ if tool.api_client.is_access_expired:
30
+ tool.api_client.refresh_access_token()
31
+ if not tool.save_token():
32
+ print("⚠️ Токен не был обновлен!")
33
+ return 1
34
+ print("✅ Токен успешно обновлен.")
35
+ else:
36
+ # logger.debug("Токен валиден, игнорируем обновление.")
37
+ print("ℹ️ Токен не истек, обновление не требуется.")
@@ -53,6 +53,6 @@ class Operation(BaseOperation):
53
53
  print(
54
54
  f"🆔 {result['id']} {full_name or 'Анонимный аккаунт'} "
55
55
  f"[ 📄 {counters['resumes_count']} "
56
- f"| 👁️ {fmt_plus(counters['new_resume_views'])} "
57
- f"| ✉️ {fmt_plus(counters['unread_negotiations'])} ]"
56
+ f"| 👁️ {fmt_plus(counters['new_resume_views'])} "
57
+ f"| ✉️ {fmt_plus(counters['unread_negotiations'])} ]"
58
58
  )
@@ -13,12 +13,15 @@ MISSING = object()
13
13
 
14
14
 
15
15
  def mapped(
16
+ *,
17
+ skip_src: bool = False,
16
18
  path: str | None = None,
17
19
  transform: Callable[[Any], Any] | None = None,
18
20
  store_json: bool = False,
19
21
  **kwargs: Any,
20
22
  ):
21
23
  metadata = kwargs.get("metadata", {})
24
+ metadata.setdefault("skip_src", skip_src)
22
25
  metadata.setdefault("path", path)
23
26
  metadata.setdefault("transform", transform)
24
27
  metadata.setdefault("store_json", store_json)
@@ -89,6 +92,8 @@ class BaseModel:
89
92
  kwargs = {}
90
93
  for f in fields(cls):
91
94
  if from_source:
95
+ if f.metadata.get("skip_src") and f.name in data:
96
+ continue
92
97
  if path := f.metadata.get("path"):
93
98
  found = True
94
99
  v = data
@@ -1,9 +1,15 @@
1
+ import secrets
2
+
1
3
  from .base import BaseModel, mapped
2
4
 
3
5
 
4
6
  # Из вакансии извлекается
5
7
  class VacancyContactsModel(BaseModel):
6
- id: int
8
+ # При вызове from_api на вакансии нужно игнорировать ее id
9
+ id: str = mapped(
10
+ skip_src=True,
11
+ default_factory=lambda: secrets.token_hex(16),
12
+ )
7
13
  vacancy_id: int = mapped(path="id")
8
14
 
9
15
  vacancy_name: str = mapped(path="name")
@@ -26,3 +32,11 @@ class VacancyContactsModel(BaseModel):
26
32
  ),
27
33
  default=None,
28
34
  )
35
+
36
+ def __post_init__(self):
37
+ self.vacancy_salary_from = (
38
+ self.vacancy_salary_from or self.vacancy_salary_to or 0
39
+ )
40
+ self.vacancy_salary_to = (
41
+ self.vacancy_salary_to or self.vacancy_salary_from or 0
42
+ )
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS employers (
16
16
  );
17
17
  /* ===================== contacts ===================== */
18
18
  CREATE TABLE IF NOT EXISTS vacancy_contacts (
19
- id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
19
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))) NOT NULL,
20
20
  vacancy_id INTEGER NOT NULL,
21
21
  -- Все это избыточные поля
22
22
  vacancy_alternate_url TEXT,
@@ -13,10 +13,16 @@ logger: logging.Logger = logging.getLogger(__package__)
13
13
 
14
14
  def init_db(conn: sqlite3.Connection) -> None:
15
15
  """Создает схему БД"""
16
+ changes_before = conn.total_changes
17
+
16
18
  conn.executescript(
17
19
  (QUERIES_PATH / "schema.sql").read_text(encoding="utf-8")
18
20
  )
19
- logger.debug("Database initialized")
21
+
22
+ if conn.total_changes > changes_before:
23
+ logger.info("Применена схема бд")
24
+ # else:
25
+ # logger.debug("База данных не изменилась.")
20
26
 
21
27
 
22
28
  def list_migrations() -> list[str]:
@@ -121,9 +121,9 @@ TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
121
121
  def collect_traceback_logs(
122
122
  fp: TextIO,
123
123
  after_dt: datetime,
124
- maxlen: int = 1000,
124
+ maxlines: int = 1000,
125
125
  ) -> str:
126
- error_lines = deque(maxlen=maxlen)
126
+ error_lines = deque(maxlen=maxlines)
127
127
  prev_line = ""
128
128
  log_dt = None
129
129
  collecting_traceback = False