fix(i18n): address PR review comments — tests, nightly build decoupling, GP test workflow

- Add pytest tests for gp_client.py (HMAC auth, HTTP errors, timeout)
- Add pytest tests for upload_strings.py and download_translations.py
- Add conftest.py to set sys.path so GP scripts can be imported from repo root
- Add scripts/gp/__init__.py to make gp a proper package for test imports
- Add gp-test.yml workflow to run GP script tests on PRs touching scripts/gp/**
- Decouple download-gp-bundle from nightly build chain:
  - Add continue-on-error: true so GP failure never blocks the build
  - Remove download-gp-bundle from needs/if of frontend-tests-linux,
    frontend-tests-windows, and release-nightly-build
  - Add warning annotation step that fires on GP download failure
This commit is contained in:
RamGopalSrikar
2026-03-27 10:50:34 -04:00
parent f90dab0582
commit d6434dff67
8 changed files with 92 additions and 153 deletions

27
.github/workflows/gp-test.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: GP Scripts Tests
on:
pull_request:
paths:
- "scripts/gp/**"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test Globalization Pipeline scripts
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r scripts/gp/requirements.txt pytest
- name: Run GP script tests
run: python -m pytest scripts/gp/tests/ -v

View File

@ -246,8 +246,12 @@ jobs:
git diff --staged --quiet || git commit -m "chore: update translations from Globalization Pipeline [skip ci]"
git push
- name: Report GP download failure
if: failure()
run: echo "::warning::GP translation download failed — translations may be out of date. Check the 'Download translations from GP' step above for details."
frontend-tests-linux:
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
if: github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
name: Run Frontend Tests - Linux
needs: [resolve-release-branch, create-nightly-tag]
uses: ./.github/workflows/typescript_test.yml
@ -263,7 +267,7 @@ jobs:
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
frontend-tests-windows:
if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
if: github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests
name: Run Frontend Tests - Windows
needs: [resolve-release-branch, create-nightly-tag]
# Windows tests are non-blocking - the release-nightly-build job only checks

0
scripts/gp/__init__.py Normal file
View File

View File

@ -0,0 +1,6 @@
"""Add scripts/gp to sys.path so bare imports (gp_client, upload_strings, etc.) resolve."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))

View File

@ -5,15 +5,11 @@ from unittest.mock import patch
import pytest
import download_translations as dl_mod
def _run_main(output_dir: str):
"""Helper to invoke download_translations.main() with an --output argument."""
with patch("sys.argv", ["download_translations.py", "--output", output_dir]):
import importlib
import scripts.gp.download_translations as dl_mod
importlib.reload(dl_mod)
dl_mod.main()
@ -28,8 +24,8 @@ SAMPLE_RESPONSE = {
class TestDownloadTranslations:
def test_writes_json_files_for_each_language(self, tmp_path):
with (
patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE),
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "es"]),
patch.object(dl_mod, "get_strings", return_value=SAMPLE_RESPONSE),
patch.object(dl_mod, "TARGET_LANGS", ["fr", "es"]),
):
_run_main(str(tmp_path))
@ -46,8 +42,8 @@ class TestDownloadTranslations:
return SAMPLE_RESPONSE
with (
patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings),
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "ja"]),
patch.object(dl_mod, "get_strings", side_effect=_get_strings),
patch.object(dl_mod, "TARGET_LANGS", ["fr", "ja"]),
):
_run_main(str(tmp_path))
@ -61,22 +57,20 @@ class TestDownloadTranslations:
return SAMPLE_RESPONSE
with (
patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings),
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "de"]),
patch.object(dl_mod, "get_strings", side_effect=_get_strings),
patch.object(dl_mod, "TARGET_LANGS", ["fr", "de"]),
pytest.raises(SystemExit) as exc_info,
):
_run_main(str(tmp_path))
assert exc_info.value.code == 1
# Successful language should still have been written
assert (tmp_path / "fr.json").exists()
def test_exits_cleanly_when_all_succeed(self, tmp_path):
with (
patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE),
patch("scripts.gp.download_translations.TARGET_LANGS", ["fr"]),
patch.object(dl_mod, "get_strings", return_value=SAMPLE_RESPONSE),
patch.object(dl_mod, "TARGET_LANGS", ["fr"]),
):
# Should not raise
_run_main(str(tmp_path))
assert (tmp_path / "fr.json").exists()
@ -90,8 +84,8 @@ class TestDownloadTranslations:
}
with (
patch("scripts.gp.download_translations.get_strings", return_value=flat_response),
patch("scripts.gp.download_translations.TARGET_LANGS", ["es"]),
patch.object(dl_mod, "get_strings", return_value=flat_response),
patch.object(dl_mod, "TARGET_LANGS", ["es"]),
):
_run_main(str(tmp_path))

View File

@ -3,10 +3,14 @@
import base64
import hashlib
import hmac
import importlib
from unittest.mock import MagicMock, patch
import pytest
import gp_client
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@ -28,16 +32,8 @@ def _expected_signature(password: str, message: str) -> str:
class TestGetHeaders:
def test_get_request_authorization_format(self):
with patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
):
import importlib
from scripts.gp import gp_client
with patch.dict("os.environ", {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}):
importlib.reload(gp_client)
headers = gp_client.get_headers("https://example.com/api", "GET")
assert headers["Authorization"].startswith("GP-HMAC user123:")
@ -46,49 +42,24 @@ class TestGetHeaders:
assert "Content-Type" not in headers
def test_put_request_includes_content_type(self):
with patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
):
import importlib
from scripts.gp import gp_client
with patch.dict("os.environ", {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}):
importlib.reload(gp_client)
headers = gp_client.get_headers("https://example.com/api", "PUT", {"key": "val"})
assert headers["Content-Type"] == "application/json"
def test_patch_request_uses_merge_patch_content_type(self):
with patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
):
import importlib
from scripts.gp import gp_client
with patch.dict("os.environ", {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}):
importlib.reload(gp_client)
headers = gp_client.get_headers("https://example.com/api", "PATCH", {})
assert headers["Content-Type"] == "application/merge-patch+json"
def test_hmac_signature_is_deterministic_for_same_inputs(self):
with patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"},
):
import importlib
from scripts.gp import gp_client
with patch.dict("os.environ", {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}):
importlib.reload(gp_client)
with patch("scripts.gp.gp_client.datetime") as mock_dt:
with patch("gp_client.datetime") as mock_dt:
mock_dt.now.return_value.strftime.return_value = "Thu, 26 Mar 2026 12:00:00 UTC"
h1 = gp_client.get_headers("https://example.com", "GET")
h2 = gp_client.get_headers("https://example.com", "GET")
@ -106,18 +77,10 @@ class TestListBundles:
mock_response.json.return_value = {"bundleIds": ["langflow-ui"]}
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.get", return_value=mock_response) as mock_get,
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.get", return_value=mock_response) as mock_get,
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
result = gp_client.list_bundles()
mock_get.assert_called_once()
@ -131,18 +94,10 @@ class TestListBundles:
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("401 Unauthorized")
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.get", return_value=mock_response),
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.get", return_value=mock_response),
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
with pytest.raises(req.exceptions.HTTPError):
gp_client.list_bundles()
@ -150,21 +105,10 @@ class TestListBundles:
import requests as req
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch(
"scripts.gp.gp_client.requests.get",
side_effect=req.exceptions.Timeout,
),
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.get", side_effect=req.exceptions.Timeout),
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
with pytest.raises(req.exceptions.Timeout):
gp_client.list_bundles()
@ -180,18 +124,10 @@ class TestUploadStrings:
mock_response.json.return_value = {"status": "ok"}
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.put", return_value=mock_response) as mock_put,
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.put", return_value=mock_response) as mock_put,
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
result = gp_client.upload_strings({"hello": "Hello"})
mock_put.assert_called_once()
@ -204,18 +140,10 @@ class TestUploadStrings:
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("500")
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.put", return_value=mock_response),
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.put", return_value=mock_response),
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
with pytest.raises(req.exceptions.HTTPError):
gp_client.upload_strings({"hello": "Hello"})
@ -231,18 +159,10 @@ class TestGetStrings:
mock_response.json.return_value = {"resourceStrings": {"hello": {"value": "Bonjour"}}}
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.get", return_value=mock_response),
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.get", return_value=mock_response),
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
result = gp_client.get_strings("fr")
assert result["resourceStrings"]["hello"]["value"] == "Bonjour"
@ -254,17 +174,9 @@ class TestGetStrings:
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("403 Forbidden")
with (
patch.dict(
"os.environ",
{"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"},
),
patch("scripts.gp.gp_client.requests.get", return_value=mock_response),
patch.dict("os.environ", {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}),
patch("gp_client.requests.get", return_value=mock_response),
):
import importlib
from scripts.gp import gp_client
importlib.reload(gp_client)
with pytest.raises(req.exceptions.HTTPError):
gp_client.get_strings("fr")

View File

@ -5,15 +5,11 @@ from unittest.mock import patch
import pytest
import upload_strings as upload_mod
def _run_main(source_path: str):
"""Helper to invoke upload_strings.main() with a --source argument."""
with patch("sys.argv", ["upload_strings.py", "--source", source_path]):
import importlib
import scripts.gp.upload_strings as upload_mod
importlib.reload(upload_mod)
upload_mod.main()
@ -23,10 +19,10 @@ class TestUploadStrings:
source.write_text(json.dumps({"hello": "Hello", "bye": "Bye"}), encoding="utf-8")
with (
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
patch("scripts.gp.upload_strings.create_bundle") as mock_create,
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
patch.object(upload_mod, "list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
patch.object(upload_mod, "create_bundle") as mock_create,
patch.object(upload_mod, "upload_strings") as mock_upload,
patch.object(upload_mod, "GP_BUNDLE", "langflow-ui"),
):
_run_main(str(source))
@ -38,10 +34,10 @@ class TestUploadStrings:
source.write_text(json.dumps({"hello": "Hello"}), encoding="utf-8")
with (
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}),
patch("scripts.gp.upload_strings.create_bundle") as mock_create,
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
patch.object(upload_mod, "list_bundles", return_value={"bundleIds": []}),
patch.object(upload_mod, "create_bundle") as mock_create,
patch.object(upload_mod, "upload_strings") as mock_upload,
patch.object(upload_mod, "GP_BUNDLE", "langflow-ui"),
):
_run_main(str(source))
@ -53,10 +49,10 @@ class TestUploadStrings:
source.write_text("{}", encoding="utf-8")
with (
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
patch("scripts.gp.upload_strings.create_bundle"),
patch("scripts.gp.upload_strings.upload_strings") as mock_upload,
patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"),
patch.object(upload_mod, "list_bundles", return_value={"bundleIds": ["langflow-ui"]}),
patch.object(upload_mod, "create_bundle"),
patch.object(upload_mod, "upload_strings") as mock_upload,
patch.object(upload_mod, "GP_BUNDLE", "langflow-ui"),
):
_run_main(str(source))
@ -66,9 +62,9 @@ class TestUploadStrings:
missing = str(tmp_path / "missing.json")
with (
patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}),
patch("scripts.gp.upload_strings.create_bundle"),
patch("scripts.gp.upload_strings.upload_strings"),
patch.object(upload_mod, "list_bundles", return_value={"bundleIds": []}),
patch.object(upload_mod, "create_bundle"),
patch.object(upload_mod, "upload_strings"),
pytest.raises(FileNotFoundError),
):
_run_main(missing)

View File

@ -19080,7 +19080,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",