fix: make council source checksum reproducible across platforms
This commit is contained in:
1 parent
74f32a3e67
commit
76bcb0db35
10 files changed
+43
-13
No files matched your search
@@ -0,0 +1,2 @@
|
||||
app/data/council_articles.csv text eol=lf
|
||||
app/data/council_articles.json text eol=lf
|
||||
@@ -155,7 +155,7 @@ node --check app/static/admin.js
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.15.2`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.15.3`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
@@ -219,6 +219,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv
|
||||
```
|
||||
|
||||
Нужна только стандартная библиотека Python. Скрипт проверяет структуру CSV и индекс,
|
||||
сохраняет число записей и SHA-256 источника и заменяет JSON атомарно после проверки.
|
||||
сохраняет число записей и SHA-256 источника с окончаниями строк LF и заменяет JSON
|
||||
атомарно после проверки. CRLF нормализуются в LF только при чтении; исходный CSV
|
||||
не перезаписывается. JSON и хеш одинаковы на Windows и Linux; Git фиксирует эти
|
||||
файлы с LF.
|
||||
После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления
|
||||
публикаций из БД продолжают отображаться без перезапуска.
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source_file": "council_articles.csv",
|
||||
"source_sha256": "7729d06503de8197c25e61f6e653a342390f274f31fd6bed43496c4cc21b8177",
|
||||
"source_sha256": "f9e6126efcb144042e89bcce284deb3e5b26dd76b1be0741962626d2b140c043",
|
||||
"source_hash_eol": "LF",
|
||||
"source_rows": 1797,
|
||||
"publications": [
|
||||
{
|
||||
|
||||
@@ -41,7 +41,9 @@ def _journal_names(data: dict) -> list[str]:
|
||||
def prepare_reference(source: Path) -> dict:
|
||||
publications = []
|
||||
by_author = defaultdict(list)
|
||||
source_bytes = source.read_bytes()
|
||||
# Git stores this text export with LF; canonicalize Windows CRLF before
|
||||
# parsing and hashing so the prepared artifact is identical on both hosts.
|
||||
source_bytes = source.read_bytes().replace(b"\r\n", b"\n")
|
||||
with StringIO(source_bytes.decode("utf-8-sig"), newline="") as stream:
|
||||
reader = csv.DictReader(stream, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
@@ -65,7 +67,7 @@ def prepare_reference(source: Path) -> dict:
|
||||
for author in sorted({_name(value) for value in authors} - {""}):
|
||||
by_author[author].append(index)
|
||||
return {"schema_version": 1, "source_file": source.name,
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
|
||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(), "source_hash_eol": "LF",
|
||||
"source_rows": len(publications), "publications": publications, "by_author": dict(by_author)}
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.15.2"
|
||||
FRONTEND_VERSION = "0.15.2"
|
||||
BACKEND_VERSION = "0.15.2"
|
||||
APP_VERSION = "0.15.3"
|
||||
FRONTEND_VERSION = "0.15.3"
|
||||
BACKEND_VERSION = "0.15.3"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -21,7 +21,7 @@ if __name__ == "__main__":
|
||||
parser.error("Output must not overwrite the source CSV")
|
||||
result = prepare_reference(args.source)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", dir=args.output.parent,
|
||||
with NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=args.output.parent,
|
||||
prefix=args.output.name + ".", suffix=".tmp", delete=False) as stream:
|
||||
stream.write(text)
|
||||
temporary = Path(stream.name)
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.15.2"
|
||||
assert response.json()["backend_version"] == "0.15.3"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.15.2 · Frontend 0.15.2" in response.text
|
||||
assert "Backend 0.15.3 · Frontend 0.15.3" in response.text
|
||||
assert "Вход в админку" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_prepared_reference_preserves_quoted_fields_metadata_and_matching(tmp_pa
|
||||
data = prepare_reference(source)
|
||||
assert source.read_bytes() == before
|
||||
assert data['source_rows'] == 3
|
||||
assert data['source_sha256'] == hashlib.sha256(before).hexdigest()
|
||||
assert data['source_sha256'] == hashlib.sha256(before.replace(b'\r\n', b'\n')).hexdigest()
|
||||
assert data['by_author'] == {'другой автор': [0], 'елкин петр': [0]}
|
||||
record = data['publications'][0]
|
||||
assert record['title'] == 'Quoted; title\ncontinuation'
|
||||
@@ -100,3 +100,25 @@ def test_converter_does_not_replace_output_when_input_is_invalid(tmp_path):
|
||||
same = subprocess.run([sys.executable, 'scripts/prepare_council_articles.py', str(source), '--output', str(source)],
|
||||
capture_output=True, text=True)
|
||||
assert same.returncode != 0 and source.read_text(encoding='utf8') == 'Wrong header\nvalue\n'
|
||||
|
||||
|
||||
def test_preparation_is_identical_for_lf_and_crlf_sources(tmp_path):
|
||||
lf_dir = tmp_path / 'lf'
|
||||
crlf_dir = tmp_path / 'crlf'
|
||||
lf_dir.mkdir()
|
||||
crlf_dir.mkdir()
|
||||
lf = lf_dir / 'source.csv'
|
||||
crlf = crlf_dir / 'source.csv'
|
||||
write_csv(crlf, [{'Название публикации': 'Multiline\npublication', 'Год': '2024',
|
||||
'Авторы НИУ ВШЭ': 'Author; Coauthor', 'Квартиль scopus': 'Q1'}])
|
||||
canonical = crlf.read_bytes().replace(b'\r\n', b'\n')
|
||||
lf.write_bytes(canonical)
|
||||
crlf.write_bytes(canonical.replace(b'\n', b'\r\n'))
|
||||
before = crlf.read_bytes()
|
||||
from_lf = prepare_reference(lf)
|
||||
from_crlf = prepare_reference(crlf)
|
||||
assert from_lf == from_crlf
|
||||
assert from_crlf['source_sha256'] == hashlib.sha256(canonical).hexdigest()
|
||||
assert from_crlf['source_hash_eol'] == 'LF'
|
||||
assert from_crlf['publications'][0]['title'] == 'Multiline\npublication'
|
||||
assert crlf.read_bytes() == before
|
||||
Reference in new issue
Block a user