diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..37b84e0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: test pytransifex + +concurrency: testing_environment + +on: + pull_request: + branches: + - master + push: + branches: + - master + workflow_dispatch: + workflow_call: + + +jobs: + tests: + runs-on: ubuntu-latest + steps: + # Not using strategy.matrix to create multiple jobs + # as we do NOT want to test with any form of concurrency + # to avoid 'race conditions' against Transifex + + - name: Check out repository code + uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: "pip" + cache-dependency-path: "requirements/*.txt" + + - name: Install project requirements + run: | + python -m pip install -U -r requirements/base.txt + python -m pip install -U -r requirements/dev.txt + + - name: Test API + env: + organization: ${{ secrets.ORGANIZATION }} + tx_token: ${{ secrets.TX_TOKEN }} + run: | + TX_TOKEN=$tx_token ORGANIZATION=$organization \ + python -m unittest ./tests/test_api.py + + - name: Test CLI + env: + organization: ${{ secrets.ORGANIZATION }} + tx_token: ${{ secrets.TX_TOKEN }} + run: | + TX_TOKEN=$tx_token ORGANIZATION=$organization \ + python -m unittest ./tests/test_cli.py + + - name: Test with a public project + env: + organization: ${{ secrets.ORGANIZATION }} + tx_token: ${{ secrets.TX_TOKEN }} + run: | + TX_TOKEN=$tx_token ORGANIZATION=$organization \ + python -m unittest ./tests/test_public_project.py + + - name: Test with Qgisplugin's 'test_translation' + env: + organization: ${{ secrets.ORGANIZATION }} + tx_token: ${{ secrets.TX_TOKEN }} + run: | + TX_TOKEN=$tx_token ORGANIZATION=$organization \ + python -m unittest ./tests/test_translation.py diff --git a/.github/workflows/test_pytx.yml b/.github/workflows/test_pytx.yml deleted file mode 100644 index d6ead2c..0000000 --- a/.github/workflows/test_pytx.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: test pytransifex - -concurrency: testing_environment - -on: - pull_request: - branches: - - master - push: - branches: - - master - workflow_call: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Check out repository code - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: "pip" - cache-dependency-path: "requirements/*.txt" - - - name: Install project requirements - run: | - python -m pip install -U -r requirements/base.txt - python -m pip install -U -r requirements/dev.txt - - - name: Run CI tests - env: - organization: ${{ secrets.ORGANIZATION }} - tx_token: ${{ secrets.TX_TOKEN }} - run: | - TX_TOKEN=$tx_token ORGANIZATION=$organization \ - python -m unittest discover -s ./tests -p 'test_*.py' diff --git a/pytransifex/api.py b/pytransifex/api.py index 73bb833..5f8f232 100644 --- a/pytransifex/api.py +++ b/pytransifex/api.py @@ -53,11 +53,14 @@ def create_project( *, project_slug: str, project_name: str | None = None, - source_language_code: str = "en_GB", + source_language_code: str = "en", private: bool = False, **kwargs, ): """Create a project.""" + logger.info( + f"Trying to create project from these arguments: project_slug = {project_slug}, " + ) source_language = tx_api.Language.get(code=source_language_code) project_name = project_name or project_slug @@ -82,12 +85,19 @@ def delete_project(self, project_slug: str): def get_project(self, project_slug: str) -> None | Resource: """Fetches the project matching the given slug""" if self.projects: + logger.info( + f"Attempting to get 'o:{self.organization_name}:p:{project_slug}'" + ) try: res = self.projects.get(slug=project_slug) logger.info("Got the project!") return res except DoesNotExist: return None + """ + except MultipleObjectsReturned: + pass + """ @ensure_login def list_resources(self, project_slug: str) -> list[Any]: @@ -247,7 +257,9 @@ def create_language( ): """Create a new language resource in the remote Transifex repository""" if project := self.get_project(project_slug=project_slug): - project.add("languages", [tx_api.Language.get(code=language_code)]) + if language := tx_api.Language.get(code=language_code): + logger.debug(f"Adding {language.code} to {project_slug}") + project.add("languages", [language]) if coordinators: project.add("coordinators", coordinators) @@ -260,12 +272,9 @@ def create_language( def project_exists(self, project_slug: str) -> bool: """Check if the project exists in the remote Transifex repository""" try: - if not self.projects: - return False - elif self.get_project(project_slug=project_slug): + if self.get_project(project_slug=project_slug): return True - else: - return False + return False except DoesNotExist: return False diff --git a/requirements/dev.txt b/requirements/dev.txt index cafd3e0..38eb3a7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1 +1,2 @@ nose2 +PyYAML diff --git a/tests/_translation.py b/tests/_translation.py new file mode 100644 index 0000000..395de84 --- /dev/null +++ b/tests/_translation.py @@ -0,0 +1,242 @@ +import glob +import logging +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from typing import Optional + +from pytransifex.api import Transifex + +logger = logging.getLogger(__name__) + + +@dataclass +class Parameters: + changelog_include: Optional[bool] = False + changelog_path: Optional[str] = None + changelog_number_of_entries: Optional[int] = None + create_date: Optional[date] = datetime.now() + github_organization_slug: Optional[str] = None + lrelease_path: Optional[str] = None + plugin_name: Optional[str] = None + plugin_path: Optional[str] = None + project_slug: Optional[str] = None + pylupdate5_path: Optional[str] = None + repository_url: Optional[str] = None + transifex_coordinator: Optional[str] = None + transifex_organization: Optional[str] = None + transifex_project: Optional[str] = None + transifex_resource: Optional[str] = None + translation_source_language: Optional[str] = None + translation_languages: Optional[str] = None + + +def touch_file(path, update_time: bool = False, create_dir: bool = True): + basedir = os.path.dirname(path) + if create_dir and not os.path.exists(basedir): + os.makedirs(basedir) + with open(path, "a"): + if update_time: + os.utime(path, None) + else: + pass + + +class Translation: + def __init__( + self, parameters: Parameters, transifex_token: str, create_project: bool = True + ): + logger.info(f"Overriding test config with: {parameters}") + client = Transifex( + api_token=transifex_token, + organization_name=parameters.transifex_organization, + i18n_type="QT", + ) + assert client + + self.tx_client = client + self.parameters = parameters + + assert self.tx_client.ping() + plugin_path = self.parameters.plugin_path + tx = self.parameters.transifex_resource + lang = self.parameters.translation_source_language + self.ts_file = f"{plugin_path}/i18n/{tx}_{lang}.ts" + + if self.tx_client.project_exists(parameters.project_slug): + logger.debug( + f"Project {self.parameters.transifex_organization}/" + f"{self.parameters.transifex_project} exists on Transifex" + ) + + elif create_project: + logger.debug( + "Project does not exists on Transifex, creating one as: " + f"{self.parameters.transifex_organization}/" + f"{self.parameters.transifex_project}" + ) + self.tx_client.create_project( + project_slug=self.parameters.project_slug, + private=False, + repository_url=self.parameters.repository_url, + source_language_code=parameters.translation_source_language, + ) + assert self.tx_client.project_exists(self.parameters.transifex_project) + self.update_strings() + logger.debug( + f"Creating resource in {self.parameters.transifex_organization}/" + f"{self.parameters.transifex_project}/" + f"{self.parameters.transifex_resource} with {self.ts_file}" + ) + self.tx_client.create_resource( + project_slug=self.parameters.project_slug, + path_to_file=self.ts_file, + resource_slug=self.parameters.transifex_resource, + ) + logger.info( + f""" + Transifex project {self.parameters.transifex_organization}/ + {self.parameters.transifex_project} and resource ({self.parameters.transifex_resource}) have been created. + """ + ) + else: + logger.error( + "Project does not exists on Transifex: " + f"{self.parameters.transifex_organization}/" + f"{self.parameters.transifex_project}" + ) + + def update_strings(self): + """ + Update TS files from plugin source strings + """ + source_py_files = [] + source_ui_files = [] + relative_path = f"./{self.parameters.plugin_path}" + for ext in ("py", "ui"): + for file in glob.glob( + f"{self.parameters.plugin_path}/**/*.{ext}", + recursive=True, + ): + file_path = str(Path(file).relative_to(relative_path)) + if ext == "py": + source_py_files.append(file_path) + else: + source_ui_files.append(file_path) + + touch_file(self.ts_file) + + project_file = Path(self.parameters.plugin_path).joinpath( + self.parameters.plugin_name + ".pro" + ) + + with open(project_file, "w") as f: + source_py_files = " ".join(source_py_files) + source_ui_files = " ".join(source_ui_files) + assert f.write("CODECFORTR = UTF-8\n") + assert f.write(f"SOURCES = {source_py_files}\n") + assert f.write(f"FORMS = {source_ui_files}\n") + assert f.write( + f"TRANSLATIONS = {Path(self.ts_file).relative_to(relative_path)}\n" + ) + f.flush() + f.close() + + cmd = [self.parameters.pylupdate5_path, "-noobsolete", str(project_file)] + + output = subprocess.run(cmd, capture_output=True, text=True) + + project_file.unlink() + + if output.returncode != 0: + logger.error(f"Translation failed: {output.stderr}") + sys.exit(1) + else: + logger.info(f"Successfully run pylupdate5: {output.stdout}") + + def compile_strings(self): + """ + Compile TS file into QM files + """ + cmd = [self.parameters.lrelease_path] + for file in glob.glob(f"{self.parameters.plugin_path}/i18n/*.ts"): + cmd.append(file) + output = subprocess.run(cmd, capture_output=True, text=True) + if output.returncode != 0: + logger.error(f"Translation failed: {output.stderr}") + sys.exit(1) + else: + logger.info(f"Successfully run lrelease: {output.stdout}") + + def pull(self): + """ + Pull TS files from Transifex + """ + resource = self.__get_resource() + existing_langs = self.tx_client.list_languages( + project_slug=self.parameters.project_slug + ) + lang = self.parameters.translation_source_language + if lang in existing_langs: + existing_langs.remove(lang) + logger.info( + f"{len(existing_langs)} languages found for resource :" + f" ({existing_langs})" + ) + for lang in self.parameters.translation_languages: + if lang not in existing_langs: + logger.debug(f"Creating missing language: {lang}") + self.tx_client.create_language( + project_slug=self.parameters.project_slug, + language_code=lang, + coordinators=[self.parameters.transifex_coordinator], + ) + existing_langs.append(lang) + for lang in existing_langs: + ts_file = f"{self.parameters.plugin_path}/i18n/{self.parameters.transifex_resource}_{lang}.ts" + logger.debug(f"Downloading translation file: {ts_file}") + self.tx_client.get_translation( + project_slug=self.parameters.project_slug, + resource_slug=resource.slug, + language_code=lang, + path_to_output_file=ts_file, + ) + + def push(self): + resource = self.__get_resource() + logger.debug( + f"Pushing resource: {self.parameters.transifex_resource} " + f"with file {self.ts_file}" + ) + result = self.tx_client.update_source_translation( + project_slug=self.parameters.project_slug, + resource_slug=resource.slug, + path_to_file=self.ts_file, + ) + logger.info(f"Translation resource updated: {result}") + + def __get_resource(self) -> dict: + resources = self.tx_client.list_resources( + project_slug=self.parameters.project_slug + ) + if len(resources) == 0: + logger.error( + f"Project '{self.parameters.project_slug}' has no resource on Transifex" + ) + sys.exit(1) + if len(resources) > 1: + for resource in resources: + if resource["name"] == self.parameters.transifex_resource: + return resource + logger.error( + f"Project '{self.parameters.transifex_project}' has several " + "resources on Transifex and none is named as the project slug. " + "Specify one in the parameters with transifex_resource." + "These resources have been found: " + f"{', '.join([r['name'] for r in resources])}" + ) + sys.exit(1) + return resources[0] diff --git a/tests/data/.qgis-plugin-ci-test-changelog.yaml b/tests/data/.qgis-plugin-ci-test-changelog.yaml new file mode 100644 index 0000000..144759a --- /dev/null +++ b/tests/data/.qgis-plugin-ci-test-changelog.yaml @@ -0,0 +1,6 @@ +project_slug: test_project_pytransifex_public +transifex_organization: test_pytransifex +transifex_coordinator: john_doe +translation_languages: + - fr + - en diff --git a/tests/test_api.py b/tests/test_api.py index 6a3c385..b96f5bd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -117,26 +117,6 @@ def test11_stats(self): logger.info(str(stats)) assert stats - def test12_stats(self): - self.tx.get_project_stats(project_slug=self.project_slug) - - def test13_stats(self): - self.tx.get_project_stats(project_slug=self.project_slug) - if __name__ == "__main__": unittest.main() - -""" -# Don't remove this! -curl -g \ - --request GET --url "https://rest.api.transifex.com/resource_language_stats?filter[project]=o%3Aopengisch%3Ap%3Aqfield-documentation" \ - --header 'accept: application/vnd.api+json' \ - --header 'authorization: Bearer TOKEN' - -curl -g \ - --request GET \ - --url "https://rest.api.transifex.com/resource_language_stats?filter[project]=o%3Atest_pytransifex%3Ap%3Atest_project_pytransifex" \ - --header 'accept: application/vnd.api+json' \ - --header 'authorization: Bearer TOKEN' -""" diff --git a/tests/test_translation.py b/tests/test_translation.py new file mode 100644 index 0000000..31315fe --- /dev/null +++ b/tests/test_translation.py @@ -0,0 +1,42 @@ +import logging +import os +import unittest +from pathlib import Path + +import yaml + +from tests._translation import Parameters, Translation + +logger = logging.getLogger(__name__) + + +class TestTranslation(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Initialize the test case""" + transifex_token = os.getenv("TX_TOKEN") + cls.transifex_token = transifex_token + assert transifex_token + + config_yaml = Path.cwd().joinpath( + "tests", "data", ".qgis-plugin-ci-test-changelog.yaml" + ) + with open(config_yaml) as f: + arg_dict = yaml.safe_load(f) + + cls.parameters = Parameters(**arg_dict) + cls.t = Translation(cls.parameters, transifex_token=transifex_token) + logger.info(f"Set up classed with {cls.parameters}") + + @classmethod + def tearDownClass(cls): + assert cls.parameters.project_slug + cls.t.tx_client.delete_project(cls.parameters.project_slug) + + def test1_creation(self): + assert self.transifex_token + self.t = Translation(self.parameters, transifex_token=self.transifex_token) + + +if __name__ == "__main__": + unittest.main()