small-software 0.6.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.
- small_cli/__init__.py +1 -0
- small_cli/__main__.py +3 -0
- small_cli/access.py +114 -0
- small_cli/archive.py +127 -0
- small_cli/cli.py +76 -0
- small_cli/commands/__init__.py +8 -0
- small_cli/commands/access.py +167 -0
- small_cli/commands/dashboard.py +49 -0
- small_cli/commands/db.py +154 -0
- small_cli/commands/deploy.py +248 -0
- small_cli/commands/destroy.py +63 -0
- small_cli/commands/domain.py +123 -0
- small_cli/commands/env.py +151 -0
- small_cli/commands/init.py +86 -0
- small_cli/commands/link.py +53 -0
- small_cli/commands/logs.py +59 -0
- small_cli/commands/mail.py +203 -0
- small_cli/commands/notify.py +147 -0
- small_cli/commands/sleep.py +61 -0
- small_cli/common.py +92 -0
- small_cli/config.py +82 -0
- small_cli/dashboard.py +648 -0
- small_cli/i18n.py +55 -0
- small_cli/railway.py +369 -0
- small_cli/scaffold.py +81 -0
- small_cli/templates/fastapi/magic_link.py +675 -0
- small_cli/templates/fastapi/main.py +67 -0
- small_cli/templates/fastapi/requirements.txt +2 -0
- small_cli/templates/node-http/magic-link.js +509 -0
- small_cli/templates/node-http/package.json +11 -0
- small_cli/templates/node-http/server.js +33 -0
- small_cli/templates/python-http/app.py +47 -0
- small_cli/templates/python-http/magic_link.py +675 -0
- small_cli/templates/streamlit/app.py +181 -0
- small_cli/templates/streamlit/requirements.txt +1 -0
- small_software-0.6.0.dist-info/METADATA +559 -0
- small_software-0.6.0.dist-info/RECORD +41 -0
- small_software-0.6.0.dist-info/WHEEL +5 -0
- small_software-0.6.0.dist-info/entry_points.txt +2 -0
- small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
- small_software-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""small notify: уведомления о запросах доступа в Telegram.
|
|
2
|
+
|
|
3
|
+
Кнопка «Запросить доступ» на экране 403 отправляет запрос в приложение; если у приложения заданы
|
|
4
|
+
TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID, middleware пересылает его владельцу в Telegram. Эта команда
|
|
5
|
+
проверяет токен бота, находит ID чата, шлёт пробное сообщение и записывает обе переменные.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from ..common import CliError, app_dir, say
|
|
18
|
+
from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state
|
|
19
|
+
from ..i18n import tr
|
|
20
|
+
from .env import add_no_deploy, change_env, report_env_change
|
|
21
|
+
|
|
22
|
+
TELEGRAM_API = "https://api.telegram.org"
|
|
23
|
+
CHAT_WAIT_SECONDS = 120
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(sub):
|
|
27
|
+
n = sub.add_parser("notify", help=tr("Telegram notifications about access requests",
|
|
28
|
+
"уведомления о запросах доступа в Telegram"),
|
|
29
|
+
description=tr("Requests from the \"Request access\" button go to the owner in Telegram. "
|
|
30
|
+
"telegram: connect a bot (token from TELEGRAM_BOT_TOKEN or hidden input); "
|
|
31
|
+
"off: turn off; with no subcommand, shows the status.",
|
|
32
|
+
"Запросы с кнопки «Запросить доступ» приходят владельцу в Telegram. "
|
|
33
|
+
"telegram — подключить бота (токен из TELEGRAM_BOT_TOKEN или ввод без эха), "
|
|
34
|
+
"off — отключить, без подкоманды — статус."))
|
|
35
|
+
n.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
|
|
36
|
+
"папка приложения (по умолчанию текущая)"))
|
|
37
|
+
n.set_defaults(func=cmd_notify_status)
|
|
38
|
+
notify_sub = n.add_subparsers(dest="notify_command", metavar="<telegram|off>")
|
|
39
|
+
nt = notify_sub.add_parser("telegram", help=tr("connect a Telegram bot", "подключить Telegram-бота"))
|
|
40
|
+
nt.add_argument("--chat-id", help=tr("chat ID, if you know it (otherwise small finds it)",
|
|
41
|
+
"ID чата, если он известен (иначе small найдёт его сам)"))
|
|
42
|
+
nt.add_argument("--wait", type=int, default=CHAT_WAIT_SECONDS, help=argparse.SUPPRESS)
|
|
43
|
+
nt.set_defaults(func=cmd_notify_telegram)
|
|
44
|
+
no = notify_sub.add_parser("off", help=tr("turn off notifications", "отключить уведомления"))
|
|
45
|
+
no.set_defaults(func=cmd_notify_off)
|
|
46
|
+
for parser in (nt, no):
|
|
47
|
+
parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
|
|
48
|
+
add_no_deploy(parser)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def telegram(token, method, params=None):
|
|
52
|
+
url = "%s/bot%s/%s" % (os.environ.get("TELEGRAM_API_URL", TELEGRAM_API).rstrip("/"), token, method)
|
|
53
|
+
req = urllib.request.Request(url, data=json.dumps(params or {}).encode("utf-8"),
|
|
54
|
+
headers={"Content-Type": "application/json"})
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
57
|
+
payload = json.loads(resp.read() or b"{}")
|
|
58
|
+
except urllib.error.HTTPError as e:
|
|
59
|
+
try:
|
|
60
|
+
payload = json.loads(e.read() or b"{}")
|
|
61
|
+
except ValueError:
|
|
62
|
+
payload = {"description": "HTTP %d" % e.code}
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise CliError(tr("cannot reach Telegram: %s", "нет связи с Telegram: %s") % getattr(e, "reason", e)) from None
|
|
65
|
+
if not payload.get("ok"):
|
|
66
|
+
raise CliError("Telegram: %s" % payload.get("description", tr("unknown error", "неизвестная ошибка")))
|
|
67
|
+
return payload["result"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bot_token():
|
|
71
|
+
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
|
72
|
+
if token:
|
|
73
|
+
return token
|
|
74
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
75
|
+
raise CliError(tr("set TELEGRAM_BOT_TOKEN (a token from @BotFather) or run the command in a terminal",
|
|
76
|
+
"задайте TELEGRAM_BOT_TOKEN (токен от @BotFather) или запустите команду в терминале"))
|
|
77
|
+
say(tr("Bot token from @BotFather (to create a bot: /newbot). Input is hidden.",
|
|
78
|
+
"Токен бота от @BotFather (создать: /newbot). Ввод не отображается."))
|
|
79
|
+
token = getpass.getpass("TELEGRAM_BOT_TOKEN: ").strip()
|
|
80
|
+
if not token:
|
|
81
|
+
raise CliError(tr("no token entered", "токен не введён"))
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def find_chat(token, username, wait):
|
|
86
|
+
"""ID чата из последнего сообщения боту; если сообщений нет — просим написать и ждём."""
|
|
87
|
+
deadline, offset, asked = time.monotonic() + wait, None, False
|
|
88
|
+
while True:
|
|
89
|
+
updates = telegram(token, "getUpdates", {"timeout": 0, **({"offset": offset} if offset else {})})
|
|
90
|
+
chats = [u["message"]["chat"] for u in updates if u.get("message", {}).get("chat")]
|
|
91
|
+
if chats:
|
|
92
|
+
return chats[-1]
|
|
93
|
+
if updates:
|
|
94
|
+
offset = updates[-1]["update_id"] + 1
|
|
95
|
+
if not asked:
|
|
96
|
+
say(tr("Send any message to @%s in Telegram. Waiting up to %d s…",
|
|
97
|
+
"Напишите боту @%s любое сообщение в Telegram — жду до %d с…") % (username, wait))
|
|
98
|
+
asked = True
|
|
99
|
+
if time.monotonic() > deadline:
|
|
100
|
+
raise CliError(tr("nobody messaged the bot within %d s. Message @%s and try again, or pass --chat-id",
|
|
101
|
+
"боту никто не написал за %d с. Напишите @%s и повторите, или передайте --chat-id")
|
|
102
|
+
% (wait, username))
|
|
103
|
+
time.sleep(2)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_notify_telegram(args):
|
|
107
|
+
root = app_dir(args.path)
|
|
108
|
+
token = bot_token()
|
|
109
|
+
username = telegram(token, "getMe")["username"]
|
|
110
|
+
if args.chat_id:
|
|
111
|
+
chat_id, who = args.chat_id, args.chat_id
|
|
112
|
+
else:
|
|
113
|
+
chat = find_chat(token, username, args.wait)
|
|
114
|
+
chat_id = chat["id"]
|
|
115
|
+
who = chat.get("title") or " ".join(filter(None, (chat.get("first_name"), chat.get("last_name")))) or chat_id
|
|
116
|
+
app = load_config(root / CONFIG_FILE)["name"] or load_state(root / STATE_FILE).get("name") or root.name
|
|
117
|
+
text = tr("✅ small: access requests for %s from the \"Request access\" button will arrive here.",
|
|
118
|
+
"✅ small: сюда будут приходить запросы доступа к %s с кнопки «Запросить доступ».") % app
|
|
119
|
+
telegram(token, "sendMessage", {"chat_id": chat_id, "text": text})
|
|
120
|
+
linked = change_env(root, {"TELEGRAM_BOT_TOKEN": token, "TELEGRAM_CHAT_ID": str(chat_id)},
|
|
121
|
+
deploy=not args.no_deploy)
|
|
122
|
+
report_env_change(linked, not args.no_deploy, tr("Notifications via @%s → %s turned on",
|
|
123
|
+
"Уведомления через @%s → %s включены") % (username, who))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_notify_off(args):
|
|
128
|
+
root = app_dir(args.path)
|
|
129
|
+
linked = change_env(root, unset=["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"], deploy=not args.no_deploy)
|
|
130
|
+
report_env_change(linked, not args.no_deploy, tr("Telegram notifications turned off",
|
|
131
|
+
"Уведомления в Telegram отключены"))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_notify_status(args):
|
|
136
|
+
env = load_config(app_dir(args.path) / CONFIG_FILE)["env"]
|
|
137
|
+
if env.get("TELEGRAM_BOT_TOKEN") and env.get("TELEGRAM_CHAT_ID"):
|
|
138
|
+
say(tr("Access request notifications: Telegram, chat %s", "Уведомления о запросах доступа: Telegram, чат %s")
|
|
139
|
+
% env["TELEGRAM_CHAT_ID"])
|
|
140
|
+
print("telegram")
|
|
141
|
+
else:
|
|
142
|
+
say(tr("Notifications are off: access requests show up only in the log (small logs). "
|
|
143
|
+
"Turn on: small notify telegram",
|
|
144
|
+
"Уведомления выключены: запросы доступа видны только в логе (small logs). "
|
|
145
|
+
"Включить: small notify telegram"))
|
|
146
|
+
print("off")
|
|
147
|
+
return 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""small sleep: режим serverless Railway — без запросов приложение засыпает и не тратит ресурсы,
|
|
2
|
+
первый запрос его будит (несколько секунд задержки)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from ..common import app_dir, linked_state, require_token, say
|
|
7
|
+
from ..i18n import tr
|
|
8
|
+
from ..railway import Railway
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(sub):
|
|
12
|
+
s = sub.add_parser("sleep", help=tr("sleep when idle to save cost: on / off / status",
|
|
13
|
+
"засыпать без запросов (экономия): on / off / статус"),
|
|
14
|
+
description=tr("Railway sleep mode (serverless): with no incoming requests the app sleeps "
|
|
15
|
+
"and uses no CPU or memory; the first request wakes it up in a few seconds. "
|
|
16
|
+
"Handy for apps that are opened a couple of times a day.",
|
|
17
|
+
"Режим сна Railway (serverless): без входящих запросов приложение засыпает и "
|
|
18
|
+
"не расходует CPU и память; первый запрос будит его за несколько секунд. "
|
|
19
|
+
"Удобно для приложений, которые открывают пару раз в день."))
|
|
20
|
+
s.add_argument("mode", nargs="?", choices=("on", "off"),
|
|
21
|
+
help=tr("turn on or off; with no argument, shows the status",
|
|
22
|
+
"включить или выключить; без аргумента — статус"))
|
|
23
|
+
s.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
|
|
24
|
+
"папка приложения (по умолчанию текущая)"))
|
|
25
|
+
s.add_argument("--no-deploy", action="store_true",
|
|
26
|
+
help=tr("don't restart the app (the setting takes effect on the next deploy)",
|
|
27
|
+
"не перезапускать приложение (настройка вступит в силу при следующем деплое)"))
|
|
28
|
+
s.set_defaults(func=cmd_sleep)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_sleep(args):
|
|
32
|
+
root = app_dir(args.path)
|
|
33
|
+
state = linked_state(root)
|
|
34
|
+
api = Railway(require_token())
|
|
35
|
+
sid, eid = state["serviceId"], state["environmentId"]
|
|
36
|
+
current = api.instance_status(sid, eid)
|
|
37
|
+
if args.mode is None:
|
|
38
|
+
deployment = current["deployment"]
|
|
39
|
+
say(tr("Sleep mode: %s%s", "Режим сна: %s%s") % (
|
|
40
|
+
tr("on, the app sleeps when there are no requests", "включён — без запросов приложение засыпает")
|
|
41
|
+
if current["sleep"] else
|
|
42
|
+
tr("off, the app is always running", "выключен — приложение работает всегда"),
|
|
43
|
+
tr("; now: %s", "; сейчас: %s") % deployment["status"] if deployment else ""))
|
|
44
|
+
print("on" if current["sleep"] else "off")
|
|
45
|
+
return 0
|
|
46
|
+
wanted = args.mode == "on"
|
|
47
|
+
if current["sleep"] == wanted:
|
|
48
|
+
say(tr("Sleep mode is already %s.", "Режим сна уже %s.")
|
|
49
|
+
% (tr("on", "включён") if wanted else tr("off", "выключен")))
|
|
50
|
+
return 0
|
|
51
|
+
api.update_instance(sid, eid, sleepApplication=wanted)
|
|
52
|
+
if not args.no_deploy:
|
|
53
|
+
api.redeploy(sid, eid)
|
|
54
|
+
say(tr("Sleep mode turned %s%s.", "Режим сна %s%s.") % (
|
|
55
|
+
tr("on", "включён") if wanted else tr("off", "выключен"),
|
|
56
|
+
tr(", Railway is restarting the app", ", Railway перезапускает приложение") if not args.no_deploy else
|
|
57
|
+
tr("; takes effect on the next deploy", "; вступит в силу при следующем деплое")))
|
|
58
|
+
if wanted:
|
|
59
|
+
say(tr("The first request after idle time takes a few seconds longer while the app wakes up.",
|
|
60
|
+
"Первый запрос после простоя будет на несколько секунд дольше: приложение просыпается."))
|
|
61
|
+
return 0
|
small_cli/common.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Общее для всех команд: ошибки, вывод, вопросы пользователю, папка приложения и токен Railway."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import STATE_FILE, load_state
|
|
9
|
+
from .i18n import tr
|
|
10
|
+
from .railway import token_from_env
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CliError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def say(message):
|
|
18
|
+
"""Прогресс и пояснения — в stderr; в stdout только результат (URL, ссылка, таблица)."""
|
|
19
|
+
print(message, file=sys.stderr, flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ask(prompt):
|
|
23
|
+
"""Ответ пользователя или None, если терминала нет (скрипт, CI) или ввод закрыт."""
|
|
24
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
25
|
+
return None
|
|
26
|
+
sys.stderr.write(prompt)
|
|
27
|
+
sys.stderr.flush()
|
|
28
|
+
try:
|
|
29
|
+
return input().strip()
|
|
30
|
+
except EOFError:
|
|
31
|
+
sys.stderr.write("\n")
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def confirm(prompt):
|
|
36
|
+
answer = ask(prompt)
|
|
37
|
+
return answer is not None and answer.lower() in ("y", "yes", "д", "да")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def app_dir(path):
|
|
41
|
+
root = Path(path).resolve()
|
|
42
|
+
if not root.is_dir():
|
|
43
|
+
raise CliError(tr("folder not found: %s", "папка не найдена: %s") % root)
|
|
44
|
+
return root
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_linked(state):
|
|
48
|
+
return all(state.get(k) for k in ("projectId", "environmentId", "serviceId"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def linked_state(root):
|
|
52
|
+
state = load_state(root / STATE_FILE)
|
|
53
|
+
if not is_linked(state):
|
|
54
|
+
raise CliError(tr("%s has not been deployed yet (no %s). Run `small deploy` first",
|
|
55
|
+
"%s ещё не задеплоено (нет %s). Сначала: small deploy") % (root, STATE_FILE))
|
|
56
|
+
return state
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def require_token():
|
|
60
|
+
token = token_from_env()
|
|
61
|
+
if not token:
|
|
62
|
+
raise CliError(tr("Railway token is not set. Create one in Railway → Account Settings → Tokens "
|
|
63
|
+
"and put it in the RAILWAY_API_TOKEN environment variable.",
|
|
64
|
+
"не задан токен Railway. Создайте его в Railway → Account Settings → Tokens "
|
|
65
|
+
"и положите в переменную окружения RAILWAY_API_TOKEN."))
|
|
66
|
+
return token
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def slugify(name):
|
|
70
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
71
|
+
return slug[:40].strip("-") or "app"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def human_size(size):
|
|
75
|
+
units = (tr("B", "Б"), tr("KB", "КБ"), tr("MB", "МБ"))
|
|
76
|
+
for unit in units:
|
|
77
|
+
if size < 1024 or unit == units[-1]:
|
|
78
|
+
return ("%d %s" if unit == units[0] else "%.1f %s") % (size, unit)
|
|
79
|
+
size /= 1024.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_elapsed(seconds):
|
|
83
|
+
return "%02d:%02d" % divmod(int(seconds), 60)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def log_time(timestamp):
|
|
87
|
+
"""ISO-время Railway (UTC, с наносекундами) -> локальное ЧЧ:ММ:СС."""
|
|
88
|
+
try:
|
|
89
|
+
moment = datetime.strptime(timestamp[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
90
|
+
return moment.astimezone().strftime("%H:%M:%S")
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return str(timestamp)
|
small_cli/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Файлы приложения: small.json (настройки пользователя) и .small.json (привязка к Railway).
|
|
2
|
+
|
|
3
|
+
Используются `small deploy` и `small dashboard`, поэтому проверки одинаковы для обоих.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from .i18n import tr
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = "small.json" # настройки пользователя: имя и переменные окружения
|
|
12
|
+
STATE_FILE = ".small.json" # куда CLI запоминает ID проекта, сервиса и домен
|
|
13
|
+
CONFIG_KEYS = ("name", "env")
|
|
14
|
+
ENV_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config(path):
|
|
22
|
+
"""Читает small.json: {"name": "...", "env": {"KEY": "value"}}. Оба поля необязательны."""
|
|
23
|
+
config = {"name": None, "env": {}}
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
return config
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(path.read_text(encoding="utf-8-sig")) # -sig: Блокнот сохраняет с BOM
|
|
28
|
+
except ValueError as e:
|
|
29
|
+
raise ConfigError(tr("%s: invalid JSON (%s)", "%s: некорректный JSON (%s)") % (CONFIG_FILE, e)) from None
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
raise ConfigError(tr("%s: expected an object like {\"name\": ..., \"env\": {...}}",
|
|
32
|
+
"%s: ожидается объект вида {\"name\": ..., \"env\": {...}}") % CONFIG_FILE)
|
|
33
|
+
unknown = sorted(set(data) - set(CONFIG_KEYS))
|
|
34
|
+
if unknown:
|
|
35
|
+
raise ConfigError(tr("%s: unknown fields %s (allowed: %s)", "%s: неизвестные поля %s (допустимы: %s)")
|
|
36
|
+
% (CONFIG_FILE, ", ".join(unknown), ", ".join(CONFIG_KEYS)))
|
|
37
|
+
|
|
38
|
+
name = data.get("name")
|
|
39
|
+
if name is not None:
|
|
40
|
+
if not isinstance(name, str) or not name.strip():
|
|
41
|
+
raise ConfigError(tr("%s: name must be a non-empty string",
|
|
42
|
+
"%s: name должен быть непустой строкой") % CONFIG_FILE)
|
|
43
|
+
config["name"] = name.strip()
|
|
44
|
+
|
|
45
|
+
env = data.get("env")
|
|
46
|
+
if env is None:
|
|
47
|
+
return config
|
|
48
|
+
if not isinstance(env, dict):
|
|
49
|
+
raise ConfigError(tr("%s: env must be an object {\"NAME\": \"value\"}",
|
|
50
|
+
"%s: env должен быть объектом {\"ИМЯ\": \"значение\"}") % CONFIG_FILE)
|
|
51
|
+
for key, value in env.items():
|
|
52
|
+
if not ENV_NAME_RE.match(key):
|
|
53
|
+
raise ConfigError(tr("%s: invalid variable name %r (Latin letters, digits, _; must not start with a digit)",
|
|
54
|
+
"%s: недопустимое имя переменной %r (латиница, цифры, _; не с цифры)")
|
|
55
|
+
% (CONFIG_FILE, key))
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
value = "true" if value else "false"
|
|
58
|
+
elif isinstance(value, (int, float)):
|
|
59
|
+
value = str(value)
|
|
60
|
+
elif not isinstance(value, str):
|
|
61
|
+
raise ConfigError(tr("%s: value of %s must be a string, a number or true/false",
|
|
62
|
+
"%s: значение %s должно быть строкой, числом или true/false") % (CONFIG_FILE, key))
|
|
63
|
+
config["env"][key] = value
|
|
64
|
+
return config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_state(path):
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
return {}
|
|
70
|
+
try:
|
|
71
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
raise ConfigError(tr("%s is corrupted (%s); delete it or run with --new",
|
|
74
|
+
"%s повреждён (%s); удалите его или запустите с --new") % (path, e)) from None
|
|
75
|
+
if not isinstance(state, dict):
|
|
76
|
+
raise ConfigError(tr("%s is corrupted (expected an object); delete it or run with --new",
|
|
77
|
+
"%s повреждён (ожидается объект); удалите его или запустите с --new") % path)
|
|
78
|
+
return state
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_state(path, state):
|
|
82
|
+
path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|