diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..111e16a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TX_TOKEN=0123456789 +ORGANIZATION=test_pytransifex diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..104d2c4 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,16 @@ +name: pre-commit + +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: pre-commit/action@v2.0.2 diff --git a/.github/workflows/test_pytx.yml b/.github/workflows/test_pytx.yml new file mode 100644 index 0000000..0ab8014 --- /dev/null +++ b/.github/workflows/test_pytx.yml @@ -0,0 +1,29 @@ +name: test pytransifex + +on: + pull_request: + branches: + - master + push: + branches: + - master + +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" + + - name: Run CI tests + env: + organization: ${{ secrets.ORGANIZATION }} + tx_token: ${{ secrets.TX_TOKEN }} + run: | + pip install -r requirements.txt + TX_TOKEN=$tx_token ORGANIZATION=$organization \ + python -m unittest discover -s ./tests -p 'test_*.py' diff --git a/.gitignore b/.gitignore index 84ef83e..08cc41f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ __pycache__/ venv/ .idea -.DS_Store \ No newline at end of file +.DS_Store +.vscode/settings.json +Pipfile +Pipfile.lock +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b066eb0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + # Fix end of files + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + args: + - '--fix=lf' + + # Remove unused imports/variables + - repo: https://github.com/myint/autoflake + rev: v1.4 + hooks: + - id: autoflake + args: + - "--in-place" + - "--remove-all-unused-imports" + - "--remove-unused-variable" + - "--ignore-init-module-imports" + + # Sort imports + - repo: https://github.com/pycqa/isort + rev: "5.12.0" + hooks: + - id: isort + args: + - --profile + - black + + # Black formatting + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black diff --git a/.travis.yml b/.travis.yml index d004b46..38b6210 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,5 +36,3 @@ deploy: on: branch: master tags: true - - diff --git a/README.md b/README.md index ab7f30d..e33e987 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,31 @@ Read the documentation: https://opengisch.github.io/pytransifex This was largely based on https://github.com/jakul/python-transifex which was not made available for Python 3. + +This can be imported as a library or run as a CLI executable (`pytx`). Read on for the latter use case. + +### Build the package (required for consuming the package as a CLI) + +Ensure that `build` is installed for the current user or project: +- current user: `pip3 --user install build` +- local project: `pipenv install --dev build` + +#### 1. Build the installable archive + +Build the package: +- current user: `pip3 -m build` +- local project: `pipenv run build` + +#### 2. Install from the archive + +This will create a `dist` in the current directory, with a `tar.gz` archive in it. You can finally install it: +- current user: `pip3 --user install ` +- local project: `pipenv install ` + +#### 3. Run the pytx cli + +Once installed the package can be run as a CLI; example: + + pytx pull name -l fr + +Run `pytx --help` for more information. diff --git a/docs/Makefile b/docs/Makefile index 298ea9e..5128596 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,4 +16,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/build_deploy.sh b/docs/build_deploy.sh index ecdb00d..9a29ae5 100755 --- a/docs/build_deploy.sh +++ b/docs/build_deploy.sh @@ -6,4 +6,4 @@ echo "building docs" pushd docs sed -i "s/__VERSION__/${TRAVIS_TAG}/" conf.py make html -popd \ No newline at end of file +popd diff --git a/docs/conf.py b/docs/conf.py index 1ed61a6..def4379 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,27 +14,28 @@ # import os import sys -sys.path.insert(0, os.path.abspath('..')) + +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- -project = 'pytransifex' -copyright = '2019, Denis Rouzaud' -author = 'Denis Rouzaud' +project = "pytransifex" +copyright = "2019, Denis Rouzaud" +author = "Denis Rouzaud" # The short X.Y version -version = '__VERSION__' +version = "__VERSION__" # The full version, including alpha/beta/rc tags -release = '__VERSION__' +release = "__VERSION__" # -- General configuration --------------------------------------------------- autoclass_content = "class" autodoc_default_flags = [ - "members", - "show-inheritance", + "members", + "show-inheritance", ] autosummary_generate = True # Make _autosummary files and include them napoleon_numpy_docstring = True # Force consistency, leave only Google @@ -44,25 +45,25 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages', - 'sphinx.ext.napoleon', - 'sphinx.ext.autosummary' + "sphinx.ext.autodoc", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -74,7 +75,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None @@ -85,7 +86,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -96,7 +97,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -112,7 +113,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'pytransifexdoc' +htmlhelp_basename = "pytransifexdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -121,15 +122,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -139,8 +137,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'pytransifex.tex', 'pytransifex Documentation', - 'Denis Rouzaud', 'manual'), + ( + master_doc, + "pytransifex.tex", + "pytransifex Documentation", + "Denis Rouzaud", + "manual", + ), ] @@ -148,10 +151,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'pytransifex', 'pytransifex Documentation', - [author], 1) -] +man_pages = [(master_doc, "pytransifex", "pytransifex Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -160,9 +160,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'pytransifex', 'pytransifex Documentation', - author, 'pytransifex', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "pytransifex", + "pytransifex Documentation", + author, + "pytransifex", + "One line description of project.", + "Miscellaneous", + ), ] @@ -181,7 +187,7 @@ # epub_uid = '' # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # -- Extension configuration ------------------------------------------------- diff --git a/pytransifex/__init__.py b/pytransifex/__init__.py index 958add5..ca0e42e 100755 --- a/pytransifex/__init__.py +++ b/pytransifex/__init__.py @@ -1,5 +1,3 @@ +import logging - - -from pytransifex.api import Transifex -from pytransifex.exceptions import TransifexException, PyTransifexException, InvalidSlugException +logging.basicConfig(level=logging.INFO) diff --git a/pytransifex/__main__.py b/pytransifex/__main__.py new file mode 100644 index 0000000..b6636bf --- /dev/null +++ b/pytransifex/__main__.py @@ -0,0 +1,4 @@ +from pytransifex.cli import cli + +if __name__ == "__main__": + cli() diff --git a/pytransifex/api.py b/pytransifex/api.py old mode 100755 new mode 100644 index 485d5be..0f59eaa --- a/pytransifex/api.py +++ b/pytransifex/api.py @@ -1,452 +1,343 @@ -#/usr/bin/python3 +import logging +from os import mkdir +from pathlib import Path +from typing import Any - -import os -import codecs import requests -import json -from urllib import parse - -from pytransifex.exceptions import PyTransifexException - - -class Transifex(object): - def __init__(self, api_token: str, organization: str, i18n_type: str = 'PO'): - """ - Initializes Transifex - - Parameters - ---------- - api_token - the API token to the service - organization - the name of the organization - i18n_type - the type of translation (PO, QT, …) - defaults to: PO - """ - self.auth = ('api', api_token) - self.api_key = api_token +from transifex.api import transifex_api as tx_api +from transifex.api.jsonapi import JsonApiException +from transifex.api.jsonapi.exceptions import DoesNotExist +from transifex.api.jsonapi.resources import Resource + +from pytransifex.config import ApiConfig +from pytransifex.interfaces import Tx +from pytransifex.utils import concurrently, ensure_login + + +class Client(Tx): + """ + The proper Transifex client expected by the cli and other consumers. + By default instances are created and logged in 'lazyly' -- when creation or login cannot be deferred any longer. + """ + + def __init__(self, config: ApiConfig, defer_login: bool = False): + """Extract config values, consumes API token against SDK client""" + self.api_token = config.api_token + self.host = config.host_name + self.organization_name = config.organization_name + self.i18n_type = config.i18n_type + self.logged_in = False + + if not defer_login: + self.login() + + def login(self): + if self.logged_in: + return + + # Authentication + tx_api.setup(host=self.host, auth=self.api_token) + self.logged_in = True + + # Saving organization and projects to avoid round-trips + organization = tx_api.Organization.get(slug=self.organization_name) + self.projects = organization.fetch("projects") self.organization = organization - self.i18n_type = i18n_type - - def create_project(self, - slug, - name: str = None, - source_language_code: str = 'en-gb', - outsource_project_name: str = None, - private: bool = False, - repository_url: str = None): - """ - Create a new project on Transifex - - Parameters - ---------- - slug - the project slug - name - the project name, defaults to the project slug - source_language_code - the source language code, defaults to 'en-gb' - outsource_project_name - the name of the project to outsource translation team management to - private - controls if this is created as a closed source or open-source - project, defaults to `False` - repository_url - The url for the repository. This is required if private is set to - False - - Raises - ------ - `PyTransifexException` - if project was not created properly - """ - if name is None: - name = slug - - url = 'https://rest.api.transifex.com/projects' - data = { - 'data': { - 'attributes': { - 'name': name, - 'slug': slug, - 'description': name, - 'private': private, - 'repository_url': repository_url - }, - 'relationships': { - 'organization': { - 'data': { - 'id': 'o:{}'.format(self.organization), - 'type': 'organizations' - } - }, - 'source_language': { - 'data': { - "id": "l:{}".format(source_language_code), - "type": "languages" - } - } - }, - 'type': 'projects' - } - - } - if outsource_project_name is not None: - data['outsource'] = outsource_project_name - - response = requests.post(url, - data=json.dumps(data), - headers={ - 'Content-Type': 'application/vnd.api+json', - 'Authorization': 'Bearer {}'.format(self.api_key) - }) - - if response.status_code != requests.codes['OK']: - print('Could not create project with data: {}'.format(data)) - raise PyTransifexException(response) - - def delete_project(self, project_slug: str): - """ - Deletes the project - - Parameters - ---------- - project_slug - the project slug + logging.info(f"Logged in as organization: {self.organization_name}") + + @ensure_login + def create_project( + self, + project_slug: str, + project_name: str | None = None, + source_language_code: str = "en_GB", + private: bool = False, + *args, # absorbing extra args + **kwargs, # absorbing extra kwargs + ) -> None | Resource: + """Create a project.""" + source_language = tx_api.Language.get(code=source_language_code) + + try: + res = tx_api.Project.create( + name=project_name, + slug=project_slug, + source_language=source_language, + private=private, + organization=self.organization, + ) + logging.info("Project created!") + return res + except JsonApiException as error: + if "already exists" in error.detail: # type: ignore + return self.get_project(project_slug=project_slug) + + @ensure_login + def get_project(self, project_slug: str) -> None | Resource: + """Fetches the project matching the given slug""" + if self.projects: + try: + res = self.projects.get(slug=project_slug) + logging.info("Got the project!") + return res + except DoesNotExist: + return None + + @ensure_login + def list_resources(self, project_slug: str) -> list[Any]: + """List all resources for the project passed as argument""" + if project := self.get_project(project_slug=project_slug): + if resources := project.fetch("resources"): + return list(resources.all()) + else: + return [] + + raise Exception( + f"Unable to find any project under this organization: '{self.organization}'" + ) - Raises - ------ - `PyTransifexException` - """ - filter_project = f"o:{self.organization}:p:{project_slug}" - url = f"https://rest.api.transifex.com/projects/{parse.quote(filter_project)}" - response = requests.delete(url, headers={'Content-Type': 'application/vnd.api+json','Authorization': 'Bearer {}'.format(self.api_key)}) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) + @ensure_login + def create_resource( + self, + project_slug: str, + path_to_file: Path, + resource_slug: str | None = None, + resource_name: str | None = None, + ): + """Create a resource using the given file contents, slugs and names""" + if not (resource_slug or resource_name): + raise Exception("Please give either a resource_slug or a resource_name") + + if project := self.get_project(project_slug=project_slug): + resource = tx_api.Resource.create( + project=project, + name=resource_name or resource_slug, + slug=resource_slug or resource_name, + i18n_format=tx_api.I18nFormat(id=self.i18n_type), + ) + + with open(path_to_file, "r") as fh: + content = fh.read() + + tx_api.ResourceStringsAsyncUpload.upload(content, resource=resource) + logging.info(f"Resource created: {resource_slug or resource_name}") - def list_resources(self, project_slug) -> list: - """ - List all resources in a project - - Parameters - ---------- - project_slug - the project slug - - Returns - ------- - list of dictionaries with resources info - each dictionary may contain - category - i18n_type - source_language_code - slug - name - - Raises - ------ - `PyTransifexException` - """ - filter_project = f"o:{self.organization}:p:{project_slug}" - url = f"https://rest.api.transifex.com/resources?filter\[project\]={parse.quote(filter_project)}" - response = requests.get(url, auth=self.auth) - - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) - - return json.loads(codecs.decode(response.content, 'utf-8')) - - def delete_team(self, team_slug: str): - filter_team = f"o:{self.organization}:t:{team_slug}" - url = f"https://rest.api.transifex.com/teams/{parse.quote(filter_team)}" - response = requests.delete(url, headers={'Content-Type': 'application/vnd.api+json','Authorization': 'Bearer {}'.format(self.api_key)}) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) - - def create_resource(self, - project_slug, - path_to_file, - resource_slug=None, - resource_name=None): + else: + raise Exception( + f"Not project could be found wiht the slug '{project_slug}'. Please create a project first." + ) + + @ensure_login + def update_source_translation( + self, project_slug: str, resource_slug: str, path_to_file: Path + ): """ - Creates a new resource with the specified slug from the given file. - - Parameters - ---------- - project_slug - the project slug - path_to_file - the path to the file which will be uploaded - resource_slug - the resource slug, defaults to a sluggified version of the filename - resource_name - the resource name, defaults to the resource name - - Raises - ------ - `PyTransifexException` - `IOError` + Update the translation strings for the given resource using the content of the file + passsed as argument """ - url = 'https://www.transifex.com/api/2/project/{p}/resources/'.format(p=project_slug) - data = { - 'name': resource_name or resource_slug, - 'slug': resource_slug, - 'i18n_type': self.i18n_type, - 'content': open(path_to_file, 'r').read() - } - response = requests.post( - url, - data=json.dumps(data), - auth=self.auth, - headers={'content-type': 'application/json'} + if not "slug" in self.organization.attributes: + raise Exception( + "Unable to fetch resource for this organization; define an 'organization slug' first." + ) + + logging.info( + f"Updating source translation for resource {resource_slug} from file {path_to_file} (project: {project_slug})." ) - if response.status_code != requests.codes['CREATED']: - raise PyTransifexException(response) + if project := self.get_project(project_slug=project_slug): + if resources := project.fetch("resources"): + if resource := resources.get(slug=resource_slug): + with open(path_to_file, "r") as fh: + content = fh.read() - def delete_resource(self, project_slug, resource_slug): - """ - Deletes the given resource - - Parameters - ---------- - project_slug - the project slug - resource_slug - the resource slug - - Raises - ------ - `PyTransifexException` - """ - url = '{u}/project/{s}/resource/{r}'.format(u=self._base_api_url, s=project_slug, r=resource_slug) - response = requests.delete(url, auth=self.auth) - if response.status_code != requests.codes['NO_CONTENT']: - raise PyTransifexException(response) + tx_api.ResourceStringsAsyncUpload.upload(content, resource=resource) + logging.info(f"Source updated for resource: {resource_slug}") + return - def update_source_translation(self, project_slug, resource_slug, - path_to_file): - """ - Update the source translation for a give resource - - Parameters - ---------- - project_slug - the project slug - resource_slug - the resource slug - path_to_file - the path to the file which will be uploaded - - Returns - ------- - dictionary with info - Info may include keys - strings_added - strings_updated - redirect - - Raises - ------ - `PyTransifexException` - `IOError` - """ - url = 'https://www.transifex.com/api/2/project/{s}/resource/{r}/content'.format( - s=project_slug, r=resource_slug - ) - content = open(path_to_file, 'r').read() - data = {'content': content} - response = requests.put( - url, data=json.dumps(data), auth=self.auth, headers={'content-type': 'application/json'}, + raise Exception( + f"Unable to find resource '{resource_slug}' in project '{project_slug}'" ) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) + @ensure_login + def get_translation( + self, + project_slug: str, + resource_slug: str, + language_code: str, + output_dir: Path, + ): + """Fetch the translation resource matching the given language""" + language = tx_api.Language.get(code=language_code) + file_name = Path.joinpath(output_dir, resource_slug) + + if project := self.get_project(project_slug=project_slug): + + if resources := project.fetch("resources"): + + if resource := resources.get(slug=resource_slug): + url = tx_api.ResourceTranslationsAsyncDownload.download( + resource=resource, language=language + ) + translated_content = requests.get(url).text + + if not Path.exists(output_dir): + mkdir(output_dir) + + with open(file_name, "w") as fh: + fh.write(translated_content) + + logging.info( + f"Translations downloaded and written to file (resource: {resource_slug})" + ) + + else: + raise Exception( + f"Unable to find any resource with this slug: '{resource_slug}'" + ) + else: + raise Exception( + f"Unable to find any resource for this project: '{project_slug}'" + ) else: - return json.loads(codecs.decode(response.content, 'utf-8')) + raise Exception( + f"Couldn't find any project with this slug: '{project_slug}'" + ) - def create_translation(self, project_slug, resource_slug, language_code, - path_to_file) -> dict: + @ensure_login + def list_languages(self, project_slug: str) -> list[Any]: """ - Creates or updates the translation for the specified language - - Parameters - ---------- - project_slug - the project slug - resource_slug - the resource slug - language_code - the language_code of the file - path_to_file - the path to the file which will be uploaded - - Returns - ------- - dictionary with info - Info may include keys - strings_added - strings_updated - redirect - - Raises - ------ - `PyTransifexException` - `IOError` + List all languages for which there is at least 1 resource registered + under the parameterised project """ - url = 'https://www.transifex.com/api/2/project/{s}/resource/{r}/translation/{l}'.format( - s=project_slug, r=resource_slug, l=language_code - ) - content = open(path_to_file, 'r').read() - data = {'content': content} - response = requests.put( - url, data=json.dumps(data), auth=self.auth, headers={'content-type': 'application/json'}, + if self.projects: + if project := self.projects.get(slug=project_slug): + languages = project.fetch("languages") + logging.info(f"Obtained these languages") + return languages + raise Exception( + f"Unable to find any project with this slug: '{project_slug}'" + ) + raise Exception( + f"Unable to find any project under this organization: '{self.organization}'" ) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) - else: - return json.loads(codecs.decode(response.content, 'utf-8')) - - def get_translation(self, - project_slug: str, - resource_slug: str, - language_code: str, - path_to_file: str): - """ - Returns the requested translation, if it exists. The translation is - returned as a serialized string, unless the GET parameter file is - specified. - - Parameters - ---------- - project_slug - The project slug - resource_slug - The resource slug - language_code - The language_code of the file. - This should be the *Transifex* language code - path_to_file - The path to the translation file which will be saved. - If the directory does not exist, it will be automatically created. - - Raises - ------ - `PyTransifexException` - `IOError` - """ - url = 'https://www.transifex.com/api/2/project/{s}/resource/{r}/translation/{l}'.format( - s=project_slug, r=resource_slug, l=language_code) - query = {'file': ''} - response = requests.get(url, auth=self.auth, params=query) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) - else: - os.makedirs(os.path.dirname(path_to_file), exist_ok=True) - with open(path_to_file, 'wb') as f: - for line in response.iter_content(): - f.write(line) + @ensure_login + def create_language( + self, + project_slug: str, + language_code: str, + coordinators: None | list[Any] = None, + ): + """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 coordinators: + project.add("coordinators", coordinators) + + logging.info(f"Created language resource for {language_code}") + + @ensure_login + def project_exists(self, project_slug: str) -> bool: + """Check if the project exists in the remote Transifex repository""" + if self.projects: + if self.projects.get(slug=project_slug): + return True + return False + raise Exception( + f"No project could be found under this organization: '{self.organization}'" + ) - def list_languages(self, project_slug, resource_slug): + @ensure_login + def ping(self) -> bool: """ - List all the languages available for a given resource in a project - - Parameters - ---------- - project_slug - The project slug - resource_slug - The resource slug - - Returns - ------- - list - The language codes which this resource has translations - - Raises - ------ - `PyTransifexException` + Exposing this just for the sake of satisfying qgis-plugin-cli's expectations + There is no need to ping the server on the current implementation, as connection is handled by the SDK """ - url = 'https://www.transifex.com/api/2/project/{s}/resource/{r}'.format(s=project_slug, r=resource_slug) - response = requests.get(url, auth=self.auth, params={'details': ''}) + logging.info("'ping' is deprecated!") + return True + + @ensure_login + def get_project_stats(self, project_slug: str) -> dict[str, Any]: + if self.projects: + if project := self.projects.get(slug=project_slug): + if resource_stats := tx_api.ResourceLanguageStats(project=project): + return resource_stats.to_dict() + + raise Exception(f"Unable to find translation for this project {project_slug}") + + @ensure_login + def pull( + self, + project_slug: str, + resource_slugs: list[str], + language_codes: list[str], + output_dir: Path, + ): + """Pull resources from project.""" + args = [] + for l_code in language_codes: + for slug in resource_slugs: + args.append(tuple([project_slug, slug, l_code, output_dir])) + + res = concurrently( + fn=self.get_translation, + args=args, + ) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) + logging.info(f"Pulled {args} for {len(res)} results).") + + @ensure_login + def push( + self, project_slug: str, resource_slugs: list[str], path_to_files: list[Path] + ): + """Push resources with files under project.""" + if len(resource_slugs) != len(path_to_files): + raise ValueError( + f"Resources slugs ({len(resource_slugs)}) and Path to files ({len(path_to_files)}) must be equal in size!" + ) + + resource_zipped_with_path = list(zip(resource_slugs, path_to_files)) + resources = self.list_resources(project_slug) + logging.info( + f"Found {len(resources)} resource(s) for {project_slug}. Checking for missing resources and creating where necessary." + ) + created_when_missing_resource = [] + + for slug, path in resource_zipped_with_path: + logging.info(f"Slug: {slug}. Resources: {resources}.") + if not slug in resources: + logging.info( + f"{project_slug} is missing {slug}. Creating it from {path}." + ) + self.create_resource( + project_slug=project_slug, path_to_file=path, resource_slug=slug + ) + created_when_missing_resource.append(slug) + + args = [ + tuple([project_slug, slug, path]) + for slug, path in resource_zipped_with_path + if not slug in created_when_missing_resource + ] + + res = concurrently( + fn=self.update_source_translation, + args=args, + ) - content = json.loads(codecs.decode(response.content, 'utf-8')) - languages = [language['code'] for language in content['available_languages']] - return languages + logging.info(f"Pushed {args} for {len(res)} results.") - def create_language(self, project_slug: str, language_code: str, coordinators: list): - """ - Create a new language for the given project - Parameters - ---------- - project_slug: - language_code: - coordinators: - list of coordinators - """ - url = 'https://www.transifex.com/api/2/project/{s}/languages'.format(s=project_slug) - data = {'language_code': language_code, 'coordinators': coordinators} - response = requests.post(url, - headers={'content-type': 'application/json'}, - auth=self.auth, - data=json.dumps(data)) - if response.status_code != requests.codes['CREATED']: - raise PyTransifexException(response) - - def coordinator(self, project_slug: str, language_code: str = 'en') -> str: - """ - Return the coordinator of the the project - Parameters - ---------- - project_slug: - language_code: - """ - url = 'https://www.transifex.com/api/2/project/{s}/language/{l}/coordinators'.format(s=project_slug, l=language_code) - response = requests.get(url, auth=self.auth) - if response.status_code != requests.codes['OK']: - raise PyTransifexException(response) - content = json.loads(codecs.decode(response.content, 'utf-8')) - return content['coordinators'] - - def project_exists(self, project_slug) -> bool: - """ - Check if there is a project with the given slug registered with - Transifex +class Transifex: + """ + Singleton factory to ensure the client is initialized at most once. + Simpler to manage than a solution relying on 'imports being imported once in Python. + """ - Parameters - ---------- - project_slug - The project slug - """ - filter_project = f"o:{self.organization}:p:{project_slug}" - url = f"https://rest.api.transifex.com/projects/{parse.quote(filter_project)}" - response = requests.get(url, - headers={ - 'Content-Type': 'application/vnd.api+json', - 'Authorization': 'Bearer {}'.format(self.api_key) - }) - if response.status_code == requests.codes['OK']: - return True - elif response.status_code == requests.codes['NOT_FOUND']: - return False - else: - raise PyTransifexException(response) + client = None - def ping(self) -> bool: - """ - Check the connection to the server and the auth credentials - """ - filter_organization = f"o:{self.organization}" - url = f"https://rest.api.transifex.com/projects?filter\[organization\]={parse.quote(filter_organization)}" - response = requests.get(url, auth=self.auth) - success = response.status_code == requests.codes['OK'] - if not success: - raise PyTransifexException(response) - return success + def __new__(cls, defer_login: bool = False): + if not cls.client: + cls.client = Client(ApiConfig.from_env(), defer_login) + return cls.client diff --git a/pytransifex/cli.py b/pytransifex/cli.py new file mode 100644 index 0000000..ca64746 --- /dev/null +++ b/pytransifex/cli.py @@ -0,0 +1,137 @@ +import logging +import traceback +from os import mkdir, rmdir +from pathlib import Path + +import click + +from pytransifex.api import Transifex +from pytransifex.config import CliSettings + +client = Transifex(defer_login=True) + + +def path_to_slug(file_paths: list[Path]) -> list[str]: + keep_last = (str(f).split("/")[-1] for f in file_paths) + remove_dot = (s.split(".")[0] for s in keep_last) + return list(remove_dot) + + +def extract_files(input_dir: Path) -> tuple[list[Path], list[str], str]: + files = list(Path.iterdir(input_dir)) + slugs = path_to_slug(files) + files_status_report = "\n".join( + (f"file: {file} => slug: {slug}" for slug, file in zip(slugs, files)) + ) + return (files, slugs, files_status_report) + + +@click.group +def cli(): + pass + + +@click.option("-v", "--verbose", is_flag=True, default=False) +@click.option("-out", "--output-directory", is_flag=False) +@click.option("-in", "--input-directory", is_flag=False) +@click.option("-org", "--organization-slug", is_flag=False) +@click.option("-p", "--project-slug", is_flag=False) +@cli.command( + "init", help="Initialize the CLI with the appropriate configuration values." +) +def init(**opts): + reply = "" + settings = CliSettings.extract_settings(**opts) + has_to_create_dir = not Path.exists(settings.output_directory) + + try: + click.echo(f"Initializing...") + + if has_to_create_dir: + mkdir(settings.output_directory) + + reply += f"Initialized project with the following settings: {settings.project_slug} and saved file to {settings.config_file} " + + if not settings.input_directory: + reply += f"WARNING: You will need to declare an input directory if you plan on using 'pytx push', as in 'pytx push --input-directory '." + + except Exception as error: + reply += ( + f"cli:init > Failed to initialize the CLI, this error occurred: {error}." + ) + + if has_to_create_dir: + rmdir(settings.output_directory) + + reply += f"Removed {settings.output_directory}. " + + finally: + click.echo(reply) + settings.to_disk() + + +@click.option("-in", "--input-directory", is_flag=False) +@cli.command("push", help="Push translation strings") +def push(input_directory: str | None): + reply = "" + settings = CliSettings.from_disk() + input_dir = ( + Path.cwd().joinpath(input_directory) + if input_directory + else getattr(settings, "input_directory", None) + ) + + if not input_dir: + raise FileExistsError( + "cli:push > To use this 'push', you need to initialize the project with a valid path to the directory containing the files to push; alternatively, you can call this commend with 'pytx push --input-directory '." + ) + + try: + files, slugs, files_status_report = extract_files(input_dir) + click.echo( + f"cli:push > Pushing {files_status_report} to Transifex under project {settings.project_slug}." + ) + client.push( + project_slug=settings.project_slug, + resource_slugs=slugs, + path_to_files=files, + ) + except Exception as error: + reply += f"cli:push > Failed because of this error: {error}" + logging.error(f"traceback: {traceback.print_exc()}") + finally: + click.echo(reply) + settings.to_disk() + + +@click.option("-l", "--only-lang", default="all") +@click.option("-out", "--output-directory", is_flag=False) +@cli.command("pull", help="Pull translation strings") +def pull(output_directory: str | Path | None, only_lang: str | None): + reply = "" + settings = CliSettings.from_disk() + language_codes = only_lang.split(",") if only_lang else [] + + if output_directory: + output_directory = Path(output_directory) + settings.output_directory = output_directory + else: + output_directory = settings.output_directory + + resource_slugs = [] + try: + click.echo( + f"Pulling translation strings ({language_codes}) from project {settings.project_slug} to {str(output_directory)}..." + ) + client.pull( + project_slug=settings.project_slug, + resource_slugs=resource_slugs, + language_codes=language_codes, + output_dir=output_directory, + ) + except Exception as error: + reply += f"cli:pull > failed because of this error: {error}" + logging.error(f"traceback: {traceback.print_exc()}") + finally: + click.echo(reply) + settings.to_disk() diff --git a/pytransifex/config.py b/pytransifex/config.py new file mode 100644 index 0000000..a8da826 --- /dev/null +++ b/pytransifex/config.py @@ -0,0 +1,98 @@ +from dataclasses import asdict, dataclass +from os import environ +from pathlib import Path +from typing import Any, NamedTuple + +import toml +from dotenv import load_dotenv + + +class ApiConfig(NamedTuple): + api_token: str + organization_name: str + i18n_type: str + host_name = "https://rest.api.transifex.com" + project_slug: str | None = None + + @classmethod + def from_env(cls) -> "ApiConfig": + load_dotenv() + + token = environ.get("TX_TOKEN") + organization = environ.get("ORGANIZATION") + i18n_type = environ.get("I18N_TYPE", "PO") + + if any(not v for v in [token, organization, i18n_type]): + raise Exception( + "Envars 'TX_TOKEN' and 'ORGANIZATION' must be set to non-empty values. Aborting now." + ) + + return cls(token, organization, i18n_type) # type: ignore + + +path_keys = ["input_directory", "output_directory", "config_file"] +mandatory = ["organization_slug", "project_slug"] +defaults = { + "output_directory": Path.cwd().joinpath("output"), + "config_file": Path.cwd().joinpath(".pytx_config.yml"), +} + + +@dataclass +class CliSettings: + organization_slug: str + project_slug: str + input_directory: Path | None + output_directory: Path = defaults["output_directory"] + config_file: Path = defaults["config_file"] + + @classmethod + def extract_settings(cls, **user_data) -> "CliSettings": + + if missing := [k for k in mandatory if not k in user_data]: + raise Exception( + f"These keys are not set or do not have a well-defined value: {', '.join(missing)}" + ) + if empty := [k for k, v in user_data.items() if k in mandatory and not v]: + raise Exception(f"These keys have an empty value: {', '.join(empty)}") + + organization_slug = user_data["organization_slug"] + project_slug = user_data["project_slug"] + input_directory = user_data.get("input_directory") + config_file = CliSettings.get_or_default("config_file", user_data) + output_directory = CliSettings.get_or_default("output_directory", user_data) + + return cls( + organization_slug, + project_slug, + input_directory, + output_directory, + config_file, + ) + + @classmethod + def from_disk(cls) -> "CliSettings": + d = toml.load(cls.config_file) + return cls.extract_settings(**d) + + def to_disk(self): + with open(self.config_file, "w") as fh: + toml.dump(self.serialize(), fh) + + def serialize(self) -> dict[str, Any]: + return { + k: (str(v) if k in path_keys else v) + for k, v in asdict(self).items() + if v is not None + } + + @staticmethod + def deserialize(d: dict[str, Any]) -> dict[str, Any]: + return {k: (Path(v) if k in path_keys else v) for k, v in d.items()} + + @staticmethod + def get_or_default(k: str, obj: dict[str, Any]) -> Any: + truthy_obj = {k: v for k, v in obj.items() if v} + if k in truthy_obj: + return truthy_obj[k] + return defaults[k] diff --git a/pytransifex/exceptions.py b/pytransifex/exceptions.py index 61a97b6..2bad48b 100755 --- a/pytransifex/exceptions.py +++ b/pytransifex/exceptions.py @@ -10,10 +10,10 @@ def __init__(self, response=None): def __str__(self): if self.response is None: return super(PyTransifexException, self).__str__() - return '{code} from {url}: {content}'.format( + return "{code} from {url}: {content}".format( code=self.response.status_code, url=self.response.url, - content=self.response.content + content=self.response.content, ) diff --git a/pytransifex/interfaces.py b/pytransifex/interfaces.py new file mode 100644 index 0000000..8268dd3 --- /dev/null +++ b/pytransifex/interfaces.py @@ -0,0 +1,80 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class Tx(ABC): + # TODO + # This interface modelled after api.py:Transifex satisfies the expectations of qgis-plugin-cli + # but it falls short of requiring an implementation for methods that qgis-plugin-cli does not use: + # { coordinator, create_translation, delete_project, delete_resource, delete_team } + + @abstractmethod + def create_project( + self, + project_slug: str, + project_name: str | None = None, + source_language_code: str = "en-gb", + outsource_project_name: str | None = None, + private: bool = False, + repository_url: str | None = None, + ): + raise NotImplementedError + + def delete_project(self, project_slug: str): + ... + + @abstractmethod + def list_resources(self, project_slug: str) -> list[Any]: + raise NotImplementedError + + def delete_team(self, team_slug: str): + ... + + @abstractmethod + def create_resource( + self, + project_slug: str, + path_to_file: str, + resource_slug: str | None = None, + resource_name: str | None = None, + ): + raise NotImplementedError + + def delete_resource(self, project_slug: str, resource_slug: str): + ... + + @abstractmethod + def update_source_translation( + self, project_slug: str, resource_slug: str, path_to_file: str + ): + raise NotImplementedError + + def create_translation( + self, project_slug: str, language_code: str, path_to_file: str + ) -> dict[str, Any]: + ... + + @abstractmethod + def get_translation( + self, project_slug: str, resource_slug: str, language: str, path_to_file: str + ): + raise NotImplementedError + + @abstractmethod + def list_languages(self, project_slug: str, resource_slug: str) -> list[Any]: + raise NotImplementedError + + @abstractmethod + def create_language(self, project_slug: str, path_to_file: str, resource_slug: str): + raise NotImplementedError + + def coordinator(self, project_slug: str, language_code="str") -> str: + ... + + @abstractmethod + def project_exists(self, project_slug: str) -> bool: + raise NotImplementedError + + @abstractmethod + def ping(self) -> bool: + raise NotImplementedError diff --git a/pytransifex/utils.py b/pytransifex/utils.py new file mode 100644 index 0000000..ea28b14 --- /dev/null +++ b/pytransifex/utils.py @@ -0,0 +1,34 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import wraps +from typing import Any, Callable + + +def ensure_login(f): + @wraps(f) + def capture_args(instance, *args, **kwargs): + if not instance.logged_in: + instance.login() + return f(instance, *args, **kwargs) + + return capture_args + + +def concurrently( + *, + fn: Callable | None = None, + args: list[Any] | None = None, + partials: list[Any] | None = None, +) -> list[Any]: + + with ThreadPoolExecutor() as pool: + if not partials is None: + assert args is None and fn is None + futures = [pool.submit(p) for p in partials] + elif (not args is None) and (not fn is None): + assert partials is None + futures = [pool.submit(fn, *a) for a in args] + else: + raise ValueError( + "Exactly 1 of 'partials' or 'args' must be defined. Found neither was when calling concurrently." + ) + return [f.result() for f in as_completed(futures)] diff --git a/requirements-locked.txt b/requirements-locked.txt new file mode 100644 index 0000000..be34f79 --- /dev/null +++ b/requirements-locked.txt @@ -0,0 +1,89 @@ +# +# This file is autogenerated by pip-compile with python 3.10 +# To update, run: +# +# pip-compile --output-file=requirements-locked.txt requirements.txt +# +alabaster==0.7.12 + # via sphinx +asttokens==2.0.8 + # via transifex-python +babel==2.10.3 + # via sphinx +certifi==2022.9.24 + # via requests +charset-normalizer==2.1.1 + # via requests +click==8.1.3 + # via + # -r requirements.txt + # transifex-python +docutils==0.17.1 + # via + # sphinx + # sphinx-rtd-theme +future==0.18.2 + # via pyseeyou +idna==3.4 + # via requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.2 + # via sphinx +markupsafe==2.1.1 + # via jinja2 +nose2==0.12.0 + # via -r requirements.txt +packaging==21.3 + # via sphinx +parsimonious==0.10.0 + # via pyseeyou +pygments==2.13.0 + # via sphinx +pyparsing==3.0.9 + # via packaging +pyseeyou==1.0.2 + # via transifex-python +python-dotenv==0.21.0 + # via -r requirements.txt +pytz==2022.5 + # via + # babel + # transifex-python +regex==2022.9.13 + # via parsimonious +requests==2.28.1 + # via + # -r requirements.txt + # sphinx + # transifex-python +six==1.16.0 + # via asttokens +snowballstemmer==2.2.0 + # via sphinx +sphinx==5.3.0 + # via + # -r requirements.txt + # sphinx-rtd-theme +sphinx-rtd-theme==1.0.0 + # via -r requirements.txt +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +toml==0.10.2 + # via -r requirements.txt +toolz==0.12.0 + # via pyseeyou +transifex-python==3.0.2 + # via -r requirements.txt +urllib3==1.26.12 + # via requests diff --git a/requirements.txt b/requirements.txt index c6e8990..ea36068 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,7 @@ requests sphinx sphinx_rtd_theme nose2 +transifex-python +click +python-dotenv +toml diff --git a/setup.py b/setup.py index 442a4df..bac75ad 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,36 @@ -from setuptools import setup import sys -python_min_version = (3, 6) +from setuptools import setup + +python_min_version = (3, 10) if sys.version_info < python_min_version: - sys.exit('pytransifex requires at least Python version {vmaj}.{vmin}.\n' - 'You are currently running this installation with\n\n{curver}'.format( - vmaj=python_min_version[0], - vmin=python_min_version[1], - curver=sys.version)) + sys.exit( + "pytransifex requires at least Python version {vmaj}.{vmin}.\n" + "You are currently running this installation with\n\n{curver}".format( + vmaj=python_min_version[0], vmin=python_min_version[1], curver=sys.version + ) + ) setup( - name = 'pytransifex', - packages = [ - 'pytransifex' - ], - version = '[VERSION]', - description = 'Yet another Python Transifex API.', - author = 'Denis Rouzaud', - author_email = 'denis.rouzaud@gmail.com', - url = 'https://github.com/opengisch/pytransifex', - download_url = 'https://github.com/opengisch/pytransifex/archive/[VERSION].tar.gz', # I'll explain this in a second - keywords = ['Transifex'], - classifiers = [ - 'Topic :: Software Development :: Localization', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Intended Audience :: System Administrators', - 'Development Status :: 3 - Alpha' - ], - install_requires = [ - 'requests' + name="pytransifex", + packages=["pytransifex"], + version="[VERSION]", + description="Yet another Python Transifex API.", + author="Denis Rouzaud", + author_email="denis.rouzaud@gmail.com", + url="https://github.com/opengisch/pytransifex", + download_url="https://github.com/opengisch/pytransifex/archive/[VERSION].tar.gz", # I'll explain this in a second + keywords=["Transifex"], + classifiers=[ + "Topic :: Software Development :: Localization", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Intended Audience :: System Administrators", + "Development Status :: 3 - Alpha", ], - python_requires=">={vmaj}.{vmin}".format(vmaj=python_min_version[0], vmin=python_min_version[1]), + install_requires=["requests", "click"], + python_requires=">={vmaj}.{vmin}".format( + vmaj=python_min_version[0], vmin=python_min_version[1] + ), + entry_points={"console_scripts": ["pytx = pytransifex.cli:cli"]}, ) diff --git a/test/test_translation.py b/test/test_translation.py deleted file mode 100644 index 3b334ff..0000000 --- a/test/test_translation.py +++ /dev/null @@ -1,43 +0,0 @@ -#! /usr/bin/env python - -import unittest -import os - -from pytransifex import Transifex, PyTransifexException - - -class TestTranslation(unittest.TestCase): - - def setUp(self): - token = os.getenv('TX_TOKEN') - assert token is not None - self.t = Transifex(organization='pytransifex', api_token=token) - self.project_slug = 'pytransifex-test-project' - self.project_name = 'PyTransifex Test project' - self.source_lang = 'fr_FR' - - def tearDown(self): - try: - self.t.delete_project(self.project_slug) - except PyTransifexException: - pass - try: - self.t.delete_team('{}-team'.format(self.project_slug)) - except PyTransifexException: - pass - - def test_creation(self): - self.tearDown() - self.t.create_project( - name=self.project_name, - slug=self.project_slug, - source_language_code=self.source_lang, - private=False, - repository_url='https://www.github.com/opengisch/pytransifex' - ) - - self.assertTrue(self.t.project_exists(self.project_slug)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/input/test_resource_fr.po b/tests/input/test_resource_fr.po new file mode 100644 index 0000000..94c0386 --- /dev/null +++ b/tests/input/test_resource_fr.po @@ -0,0 +1,26 @@ +msgid "" +msgstr "" +"Project-Id-Version: Lingohub 1.0.1\n" +"Report-Msgid-Bugs-To: support@lingohub.com \n" +"Last-Translator: Marko Bošković \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "Let’s make the web multilingual." +msgstr "Machen wir das Internet mehrsprachig." + +msgid "We connect developers and translators around the globe " +"on Lingohub for a fantastic localization experience." +msgstr "Wir verbinden Entwickler mit Übersetzern weltweit " +"auf Lingohub für ein fantastisches Lokalisierungs-Erlebnis." + +msgid "Welcome back, %1$s! Your last visit was on %2$s" +msgstr "Willkommen zurück, %1$s! Dein letzter Besuch war am %2$s" + +msgid "%d page read." +msgid_plural "%d pages read." +msgstr[0] "Eine Seite gelesen wurde." +msgstr[1] "%d Seiten gelesen wurden." diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..25efa03 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,118 @@ +import unittest +from pathlib import Path +from shutil import rmtree + +from pytransifex.api import Transifex +from pytransifex.interfaces import Tx + + +class TestNewApi(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tx = Transifex(defer_login=True) + cls.project_slug = "test_project_pytransifex" + cls.project_name = "Test Project PyTransifex" + cls.resource_slug = "test_resource_fr" + cls.resource_name = "Test Resource FR" + cls.path_to_file = Path.cwd().joinpath("tests", "input", "test_resource_fr.po") + cls.output_dir = Path.cwd().joinpath("tests", "output") + + if missing := next(filter(lambda p: not p.exists(), [cls.path_to_file]), None): + raise ValueError( + f"Unable to complete test with broken tests inputs. Found missing: {missing}" + ) + + if project := cls.tx.get_project(project_slug=cls.project_slug): + print("Found old project, removing.") + project.delete() + + print("Creating a brand new project") + cls.tx.create_project( + project_name=cls.project_name, project_slug=cls.project_slug, private=True + ) + + @classmethod + def tearDownClass(cls): + if Path.exists(cls.output_dir): + rmtree(cls.output_dir) + + def test1_new_api_satisfies_abc(self): + assert isinstance(self.tx, Tx) + + def test2_create_project(self): + # Done in setUpClass + pass + + def test3_list_resources(self): + _ = self.tx.list_resources(project_slug=self.project_slug) + assert True + + def test4_create_resource(self): + self.tx.create_resource( + project_slug=self.project_slug, + resource_name=self.resource_name, + resource_slug=self.resource_slug, + path_to_file=self.path_to_file, + ) + assert True + + def test5_update_source_translation(self): + self.tx.update_source_translation( + project_slug=self.project_slug, + path_to_file=self.path_to_file, + resource_slug=self.resource_slug, + ) + assert True + + def test6_create_language(self): + self.tx.create_language(self.project_slug, "fr_CH") + + def test7_list_languages(self): + languages = self.tx.list_languages(project_slug=self.project_slug) + assert languages is not None + + def test8_get_translation(self): + self.tx.get_translation( + project_slug=self.project_slug, + resource_slug=self.resource_slug, + language_code="fr_CH", + output_dir=self.output_dir, + ) + assert Path.exists(Path.joinpath(self.output_dir, self.resource_slug)) + + def test9_project_exists(self): + verdict = self.tx.project_exists(project_slug=self.project_slug) + assert verdict is not None + + def test10_ping(self): + self.tx.ping() + assert True + + def test11_stats(self): + stats = self.tx.get_project_stats(project_slug=self.project_slug) + print(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_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3b8af21 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,69 @@ +import unittest +from os import remove +from pathlib import Path + +from click.testing import CliRunner + +from pytransifex.api import Transifex +from pytransifex.cli import cli + + +class TestCli(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tx = Transifex(defer_login=True) + cls.project_slug = "test_project_pytransifex" + cls.project_name = "Test Project PyTransifex" + cls.resource_slug = "test_resource_fr" + cls.resource_name = "Test Resource FR" + cls.path_to_input_dir = Path.cwd().joinpath("tests", "input") + cls.path_to_file = cls.path_to_input_dir.joinpath("test_resource_fr.po") + cls.output_dir = Path.cwd().joinpath("tests", "output") + + if missing := next( + filter(lambda p: not p.exists(), [cls.path_to_file, cls.path_to_input_dir]), + None, + ): + raise ValueError( + f"Unable to complete test with broken tests inputs. Found missing: {missing}" + ) + + if project := cls.tx.get_project(project_slug=cls.project_slug): + print("Found old project, removing.") + project.delete() + + print("Creating a brand new project") + cls.tx.create_project( + project_name=cls.project_name, project_slug=cls.project_slug, private=True + ) + + cls.runner = CliRunner() + + @classmethod + def tearDownClass(cls): + if Path.exists(cls.output_dir): + remove(cls.output_dir) + + def test1_init(self): + result = self.runner.invoke( + cli, + ["init", "-p", self.project_slug, "-org", "test_pytransifex"], + ) + passed = result.exit_code == 0 + assert passed + + def test2_push(self): + result = self.runner.invoke(cli, ["push", "-in", str(self.path_to_input_dir)]) + passed = result.exit_code == 0 + print(result.output) + assert passed + + def test3_pull(self): + result = self.runner.invoke(cli, ["pull", "-l", "fr_CH,en_GB"]) + passed = result.exit_code == 0 + print(result.output) + assert passed + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b5d4b35 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,28 @@ +import unittest +from functools import partial +from time import sleep as tsleep + +from pytransifex.utils import concurrently + + +def fn(a: int, b: int) -> int: + tsleep(a) + return a + b + + +class TestUtils(unittest.TestCase): + @classmethod + def setUpClass(cls): + it = [1, 2, 3] + cls.it = it + cls.args = [tuple([a, b]) for a, b in zip(it, it)] + cls.partials = [partial(fn, a, b) for a, b in zip(it, it)] + cls.res = [2, 4, 6] + + def test1_map_async(self): + res = concurrently(partials=self.partials) + assert res == self.res + + def test2_map_async(self): + res = concurrently(fn=fn, args=self.args) + assert res == self.res