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
@@ -1,109 +1,117 @@
1
- # Этот модуль можно использовать как образец для других
1
+ from __future__ import annotations
2
+
2
3
  import argparse
4
+ import datetime as dt
3
5
  import logging
4
- from datetime import datetime, timedelta, timezone
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..api.errors import ApiError
9
+ from ..main import BaseNamespace, BaseOperation
10
+ from ..utils.date import parse_api_datetime
5
11
 
6
- from ..api import ApiClient, ClientError
7
- from ..constants import INVALID_ISO8601_FORMAT
8
- from ..main import BaseOperation
9
- from ..main import Namespace as BaseNamespace
10
- from ..types import ApiListResponse
11
- from ..utils import print_err, truncate_string
12
+ if TYPE_CHECKING:
13
+ from ..main import HHApplicantTool
12
14
 
13
15
  logger = logging.getLogger(__package__)
14
16
 
15
17
 
16
18
  class Namespace(BaseNamespace):
17
- older_than: int
19
+ cleanup: bool
18
20
  blacklist_discard: bool
19
- all: bool
21
+ older_than: int
22
+ dry_run: bool
20
23
 
21
24
 
22
25
  class Operation(BaseOperation):
23
- """Отменяет старые отклики, скрывает отказы с опциональной блокировкой работодателя."""
26
+ """Удаляет отказы либо старые отклики."""
27
+
28
+ __aliases__ = ["clear-negotiations", "delete-negotiations"]
24
29
 
25
30
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
26
31
  parser.add_argument(
27
- "--older-than",
28
- type=int,
29
- default=30,
30
- help="Удалить отклики старше опр. кол-ва дней. По умолчанию: %(default)d",
32
+ "-b",
33
+ "--blacklist-discard",
34
+ "--blacklist",
35
+ action=argparse.BooleanOptionalAction,
36
+ help="Блокировать работодателя за отказ",
31
37
  )
32
38
  parser.add_argument(
33
- "--all",
34
- action=argparse.BooleanOptionalAction,
35
- help="Удалить все отклики в тч с приглашениями",
39
+ "-o",
40
+ "--older-than",
41
+ type=int,
42
+ help="С флагом --clean удаляет любые отклики старше N дней",
36
43
  )
37
44
  parser.add_argument(
38
- "--blacklist-discard",
39
- help="Если установлен, то заблокирует работодателя в случае отказа, чтобы его вакансии не отображались в возможных",
45
+ "-n",
46
+ "--dry-run",
40
47
  action=argparse.BooleanOptionalAction,
48
+ help="Тестовый запуск без реального удаления",
41
49
  )
42
50
 
43
- def _get_active_negotiations(self, api_client: ApiClient) -> list[dict]:
44
- rv = []
45
- page = 0
46
- per_page = 100
47
- while True:
48
- r: ApiListResponse = api_client.get(
49
- "/negotiations", page=page, per_page=per_page, status="active"
50
- )
51
- rv.extend(r["items"])
52
- page += 1
53
- if page >= r["pages"]:
54
- break
55
- return rv
56
-
57
- def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
58
- negotiations = self._get_active_negotiations(api_client)
59
- print("Всего активных:", len(negotiations))
60
- for item in negotiations:
61
- state = item["state"]
62
- # messaging_status archived
63
- # decline_allowed False
64
- # hidden True
65
- is_discard = state["id"] == "discard"
66
- if not item["hidden"] and (
67
- args.all
68
- or is_discard
69
- or (
70
- state["id"] == "response"
71
- and (datetime.utcnow() - timedelta(days=args.older_than)).replace(
72
- tzinfo=timezone.utc
51
+ def run(self, tool: HHApplicantTool) -> None:
52
+ self.tool = tool
53
+ self.args: Namespace = tool.args
54
+ self.clear()
55
+
56
+ def clear(self) -> None:
57
+ blacklisted = set(self.tool.get_blacklisted())
58
+ storage = self.tool.storage
59
+ for negotiation in self.tool.get_negotiations():
60
+ vacancy = negotiation["vacancy"]
61
+
62
+ # Если работодателя блокируют, то он превращается в null
63
+ # ХХ позволяет скрывать компанию, когда id нет, а вместо имени "Крупная российская компания"
64
+ # sqlite3.IntegrityError: NOT NULL constraint failed: negotiations.employer_id
65
+ # try:
66
+ # storage.negotiations.save(negotiation)
67
+ # except RepositoryError as e:
68
+ # logger.exception(e)
69
+
70
+ if self.args.older_than:
71
+ updated_at = parse_api_datetime(negotiation["updated_at"])
72
+ # А хз какую временную зону сайт возвращает
73
+ days_passed = (
74
+ dt.datetime.now(updated_at.tzinfo) - updated_at
75
+ ).days
76
+ logger.debug(f"{days_passed = }")
77
+ if days_passed <= self.args.older_than:
78
+ continue
79
+ elif negotiation["state"]["id"] != "discard":
80
+ continue
81
+ try:
82
+ if not self.args.dry_run:
83
+ self.tool.api_client.delete(
84
+ f"/negotiations/active/{negotiation['id']}",
85
+ with_decline_message=True,
73
86
  )
74
- > datetime.strptime(item["updated_at"], INVALID_ISO8601_FORMAT)
75
- )
76
- ):
77
- decline_allowed = item.get("decline_allowed") or False
78
- r = api_client.delete(
79
- f"/negotiations/active/{item['id']}",
80
- with_decline_message=decline_allowed,
81
- )
82
- assert {} == r
83
- vacancy = item["vacancy"]
87
+
84
88
  print(
85
- " Удалили",
86
- state["name"].lower(),
89
+ "🗑️ Отменили отклик на вакансию:",
87
90
  vacancy["alternate_url"],
88
- "(",
89
- truncate_string(vacancy["name"]),
90
- ")",
91
+ vacancy["name"],
91
92
  )
92
- if is_discard and args.blacklist_discard:
93
- employer = vacancy.get("employer", {})
94
- if not employer or 'id' not in employer:
95
- # Работодатель удален или скрыт
96
- continue
97
- try:
98
- r = api_client.put(f"/employers/blacklisted/{employer['id']}")
99
- assert not r
100
- print(
101
- "🚫 Заблокировали",
102
- employer["alternate_url"],
103
- "(",
104
- truncate_string(employer["name"]),
105
- ")",
93
+
94
+ employer = vacancy.get("employer", {})
95
+ employer_id = employer.get("id")
96
+
97
+ if (
98
+ self.args.blacklist_discard
99
+ and employer
100
+ and employer_id
101
+ and employer_id not in blacklisted
102
+ ):
103
+ if not self.args.dry_run:
104
+ self.tool.api_client.put(
105
+ f"/employers/blacklisted/{employer_id}"
106
106
  )
107
- except ClientError as ex:
108
- print_err("❗ Ошибка:", ex)
109
- print("🧹 Чистка заявок завершена!")
107
+ blacklisted.add(employer_id)
108
+
109
+ print(
110
+ "🚫 Работодатель заблокирован:",
111
+ employer["name"],
112
+ employer["alternate_url"],
113
+ )
114
+ except ApiError as err:
115
+ logger.error(err)
116
+
117
+ print("✅ Удаление откликов завершено.")
@@ -1,49 +1,152 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
4
+ import json
2
5
  import logging
3
6
  import os
7
+ import platform
4
8
  import subprocess
5
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any
6
10
 
7
- from ..main import BaseOperation
8
- from ..main import Namespace as BaseNamespace
11
+ from ..main import BaseNamespace, BaseOperation
9
12
 
10
- logger = logging.getLogger(__package__)
13
+ if TYPE_CHECKING:
14
+ from ..main import HHApplicantTool
11
15
 
12
- EDITOR = os.getenv("EDITOR", "nano")
16
+
17
+ logger = logging.getLogger(__package__)
13
18
 
14
19
 
15
20
  class Namespace(BaseNamespace):
16
21
  show_path: bool
17
22
  key: str
23
+ set: list[str]
24
+ edit: bool
25
+ unset: str
18
26
 
19
27
 
20
28
  def get_value(data: dict[str, Any], path: str) -> Any:
21
29
  for key in path.split("."):
22
- if key not in data:
30
+ if isinstance(data, dict):
31
+ data = data.get(key)
32
+ else:
23
33
  return None
24
- data = data[key]
25
34
  return data
26
35
 
27
36
 
37
+ def set_value(data: dict[str, Any], path: str, value: Any) -> None:
38
+ """Устанавливает значение во вложенном словаре по ключу в виде строки."""
39
+ keys = path.split(".")
40
+ for key in keys[:-1]:
41
+ data = data.setdefault(key, {})
42
+ data[keys[-1]] = value
43
+
44
+
45
+ def del_value(data: dict[str, Any], path: str) -> None:
46
+ """Удаляет значение из вложенного словаря по ключу в виде строки."""
47
+ keys = path.split(".")
48
+ for key in keys[:-1]:
49
+ if not isinstance(data, dict) or key not in data:
50
+ return False
51
+ data = data[key]
52
+
53
+ try:
54
+ del data[keys[-1]]
55
+ return True
56
+ except KeyError:
57
+ return False
58
+
59
+
60
+ def parse_scalar(value: str) -> bool | int | float | str:
61
+ if value == "null":
62
+ return None
63
+ if value in ["true", "false"]:
64
+ return "t" in value
65
+ try:
66
+ return float(value) if "." in value else int(value)
67
+ except ValueError:
68
+ return value
69
+
70
+
28
71
  class Operation(BaseOperation):
29
- """Операции с конфигурационным файлом"""
72
+ """
73
+ Операции с конфигурационным файлом.
74
+ По умолчанию выводит содержимое конфига.
75
+ """
30
76
 
31
77
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
32
- parser.add_argument(
78
+ group = parser.add_mutually_exclusive_group()
79
+ group.add_argument(
80
+ "-e",
81
+ "--edit",
82
+ action="store_true",
83
+ help="Открыть конфигурационный файл в редакторе",
84
+ )
85
+ group.add_argument(
86
+ "-k", "--key", help="Вывести отдельное значение из конфига"
87
+ )
88
+ group.add_argument(
89
+ "-s",
90
+ "--set",
91
+ nargs=2,
92
+ metavar=("KEY", "VALUE"),
93
+ help="Установить значение в конфиг, например, --set openai.model gpt-4o",
94
+ )
95
+ group.add_argument(
96
+ "-u", "--unset", metavar="KEY", help="Удалить ключ из конфига"
97
+ )
98
+ group.add_argument(
33
99
  "-p",
34
100
  "--show-path",
35
101
  "--path",
36
- action=argparse.BooleanOptionalAction,
102
+ action="store_true",
37
103
  help="Вывести полный путь к конфигу",
38
104
  )
39
- parser.add_argument("-k", "--key", help="Вывести отдельное значение из конфига")
40
105
 
41
- def run(self, args: Namespace, *_) -> None:
106
+ def run(self, applicant_tool: HHApplicantTool) -> None:
107
+ args = applicant_tool.args
108
+ config = applicant_tool.config
109
+ if args.set:
110
+ key, value = args.set
111
+ set_value(config, key, parse_scalar(value))
112
+ config.save()
113
+ logger.info("Значение '%s' для ключа '%s' сохранено.", value, key)
114
+ return
115
+
116
+ if args.unset:
117
+ key = args.unset
118
+ if del_value(config, key):
119
+ config.save()
120
+ logger.info("Ключ '%s' удален из конфига.", key)
121
+ else:
122
+ logger.warning("Ключ '%s' не найден в конфиге.", key)
123
+ return
124
+
42
125
  if args.key:
43
- print(get_value(args.config, args.key))
126
+ value = get_value(config, args.key)
127
+ if value is not None:
128
+ print(value)
44
129
  return
45
- config_path = str(args.config._config_path)
130
+
131
+ config_path = str(config._config_path)
46
132
  if args.show_path:
47
133
  print(config_path)
48
- else:
49
- subprocess.call([EDITOR, config_path])
134
+ return
135
+
136
+ if args.edit:
137
+ self._open_editor(config_path)
138
+ return
139
+
140
+ # Default action: show content
141
+ print(json.dumps(config, indent=2, ensure_ascii=False))
142
+
143
+ def _open_editor(self, filepath: str) -> None:
144
+ """Открывает файл в редакторе по умолчанию в зависимости от ОС."""
145
+ match platform.system():
146
+ case "Windows":
147
+ os.startfile(filepath)
148
+ case "Darwin": # macOS
149
+ subprocess.run(["open", filepath], check=True)
150
+ case _: # Linux и остальные (аналог else)
151
+ editor = os.getenv("EDITOR", "xdg-open")
152
+ subprocess.run([editor, filepath], check=True)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from runpy import run_module
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..main import BaseNamespace, BaseOperation
10
+
11
+ if TYPE_CHECKING:
12
+ from ..main import HHApplicantTool
13
+
14
+
15
+ logger = logging.getLogger(__package__)
16
+
17
+
18
+ class Namespace(BaseNamespace):
19
+ pass
20
+
21
+
22
+ class Operation(BaseOperation):
23
+ """Установит Chromium и другие зависимости"""
24
+
25
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
26
+ pass
27
+
28
+ def run(self, applicant_tool: HHApplicantTool) -> None:
29
+ orig_argv = sys.argv
30
+ sys.argv = ["playwright", "install", "chromium"]
31
+ try:
32
+ run_module("playwright", run_name="__main__")
33
+ finally:
34
+ sys.argv = orig_argv
@@ -1,14 +1,19 @@
1
- # Этот модуль можно использовать как образец для других
1
+ from __future__ import annotations
2
+
2
3
  import argparse
3
4
  import logging
5
+ from typing import TYPE_CHECKING
4
6
 
5
7
  from prettytable import PrettyTable
6
8
 
7
- from ..api import ApiClient
8
- from ..main import BaseOperation
9
- from ..main import Namespace as BaseNamespace
10
- from ..types import ApiListResponse
11
- from ..utils import truncate_string
9
+ from ..api.datatypes import PaginatedItems
10
+ from ..main import BaseNamespace, BaseOperation
11
+ from ..utils.string import shorten
12
+
13
+ if TYPE_CHECKING:
14
+ from ..api import datatypes
15
+ from ..main import HHApplicantTool
16
+
12
17
 
13
18
  logger = logging.getLogger(__package__)
14
19
 
@@ -20,20 +25,27 @@ class Namespace(BaseNamespace):
20
25
  class Operation(BaseOperation):
21
26
  """Список резюме"""
22
27
 
28
+ __aliases__ = ("ls-resumes", "resumes")
29
+
23
30
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
24
31
  pass
25
32
 
26
- def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
27
- resumes: ApiListResponse = api_client.get("/resumes/mine")
28
- t = PrettyTable(field_names=["ID", "Название", "Статус"], align="l", valign="t")
33
+ def run(self, tool: HHApplicantTool) -> None:
34
+ resumes: PaginatedItems[datatypes.Resume] = tool.get_resumes()
35
+ logger.debug(resumes)
36
+ tool.storage.resumes.save_batch(resumes)
37
+
38
+ t = PrettyTable(
39
+ field_names=["ID", "Название", "Статус"], align="l", valign="t"
40
+ )
29
41
  t.add_rows(
30
42
  [
31
43
  (
32
44
  x["id"],
33
- truncate_string(x["title"]),
45
+ shorten(x["title"]),
34
46
  x["status"]["name"].title(),
35
47
  )
36
- for x in resumes["items"]
48
+ for x in resumes
37
49
  ]
38
50
  )
39
51
  print(t)
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ..main import BaseNamespace, BaseOperation
12
+
13
+ if TYPE_CHECKING:
14
+ from ..main import HHApplicantTool
15
+
16
+
17
+ logger = logging.getLogger(__package__)
18
+
19
+
20
+ class Namespace(BaseNamespace):
21
+ follow: bool
22
+
23
+
24
+ class Operation(BaseOperation):
25
+ """Просмотр файла-лога"""
26
+
27
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
28
+ # Изменено на -F для соответствия стандарту less/tail
29
+ parser.add_argument(
30
+ "-f",
31
+ "--follow",
32
+ action="store_true",
33
+ help="Следить за файлом (режим follow, аналог less +F)",
34
+ )
35
+
36
+ def run(self, tool: HHApplicantTool) -> None:
37
+ log_path = tool.log_file
38
+
39
+ if not os.path.exists(log_path):
40
+ logger.error("Файл лога не найден: %s", log_path)
41
+ return 1
42
+
43
+ if sys.platform == "win32":
44
+ os.startfile(log_path)
45
+ return
46
+
47
+ pager = os.getenv("PAGER", "less")
48
+ if not shutil.which(pager):
49
+ logger.error("Не найден просмотрщик '%s'", pager)
50
+ if pager == "less":
51
+ logger.error(
52
+ 'Попробуйте установить less: "sudo apt install less" или "sudo yum install less"'
53
+ )
54
+ return 1
55
+
56
+ cmd = [pager]
57
+
58
+ if pager == "less":
59
+ # -R позволяет отображать цвета (ANSI codes)
60
+ # -S отключает перенос строк (удобно для логов)
61
+ cmd.extend(["-R", "-S"])
62
+ if tool.args.follow:
63
+ # В less режим слежения включается через команду +F
64
+ cmd.append("+F")
65
+
66
+ cmd.append(str(log_path))
67
+
68
+ try:
69
+ # Запускаем процесс. check=False, так как выход из pager
70
+ # по Ctrl+C может вернуть ненулевой код.
71
+ subprocess.run(cmd, check=False)
72
+ except FileNotFoundError:
73
+ logger.error("Не удалось запустить просмотрщик '%s'", pager)
74
+ return 1
75
+ except KeyboardInterrupt:
76
+ # Обработка прерывания, чтобы не выводить traceback в консоль
77
+ pass
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..main import BaseNamespace, BaseOperation
11
+ from ..storage import apply_migration, list_migrations
12
+
13
+ if TYPE_CHECKING:
14
+ from ..main import HHApplicantTool
15
+
16
+ SUCKASS = "✅ Success!"
17
+
18
+ logger = logging.getLogger(__package__)
19
+
20
+
21
+ class Namespace(BaseNamespace):
22
+ pass
23
+
24
+
25
+ class Operation(BaseOperation):
26
+ """Выполняет миграцию БД. Если первым аргументом имя миграции не передано, выведет их список.""" # noqa: E501
27
+
28
+ __aliases__: list[str] = ["migrate"]
29
+
30
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
31
+ parser.add_argument("name", nargs="?", help="Имя миграции")
32
+
33
+ def run(self, tool: HHApplicantTool) -> None:
34
+ def apply(name: str) -> None:
35
+ apply_migration(tool.db, name)
36
+ print(SUCKASS)
37
+
38
+ try:
39
+ if a := tool.args.name:
40
+ return apply(a)
41
+ if not (migrations := list_migrations()):
42
+ return
43
+ if not sys.stdout.isatty():
44
+ print(*list_migrations(), sep=os.sep)
45
+ return
46
+ print("List of migrations:")
47
+ print()
48
+ for n, migration in enumerate(migrations, 1):
49
+ print(f" [{n}]: {migration}")
50
+ print()
51
+ L = len(migrations)
52
+ if n := int(
53
+ input(
54
+ f"Choose migration [1{f'-{L}' if L > 1 else ''}] (Keep empty to exit): " # noqa: E501
55
+ )
56
+ or 0
57
+ ):
58
+ apply(migrations[n - 1])
59
+ except sqlite3.OperationalError as ex:
60
+ logger.exception(ex)
61
+ logger.warning(
62
+ f"Если ничего не помогает, то вы можете просто удалить базу, сделав бекап:\n\n"
63
+ f" $ mv {tool.db_path}{{,.bak}}"
64
+ )
65
+ return 1