diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fb06a8d33..5decf8c88 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -51,16 +51,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Python 3.9 + - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' architecture: ${{ matrix.architecture }} - - name: Set up Python Dependencies + - name: Setup Python Dependencies run: | - python -m pip install --upgrade pip setuptools - python -m pip install -r requirements-dev.txt --no-warn-script-location + python -m pip install --upgrade pip setuptools wheel + python -m pip install -r requirements-dev.txt - name: Compile Locale Translations run: | @@ -108,7 +108,7 @@ jobs: --tb=native \ --verbose \ --color=yes \ - --cov=pyra \ + --cov=src \ tests - name: Upload coverage diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml index b0a81be38..b08cc62e8 100644 --- a/.github/workflows/localize.yml +++ b/.github/workflows/localize.yml @@ -6,9 +6,8 @@ on: branches: [master] paths: # prevents workflow from running unless these files change - '.github/workflows/localize.yml' - - 'retroarcher.py' - 'locale/retroarcher.po' - - 'pyra/**.py' + - 'src/**.py' - 'web/templates/**' workflow_dispatch: @@ -21,14 +20,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Python 3.9 + - name: Install Python uses: actions/setup-python@v5 # https://github.com/actions/setup-python with: - python-version: '3.9' + python-version: '3.12' - - name: Set up Python 3.9 Dependencies + - name: Setup Python Dependencies run: | - python -m pip install --upgrade pip setuptools + python -m pip install --upgrade pip setuptools wheel python -m pip install -r requirements.txt - name: Update Strings diff --git a/.gitignore b/.gitignore index 7d88bf59b..27b363c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -155,8 +155,5 @@ cython_debug/ node_modules/ *package-lock.json -# RetroArcher directories -logs/ - -# RetroArcher files -*config.ini +# project files and directories +config/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 511b87cbd..e369c4776 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,9 +8,9 @@ version: 2 # Set the version of Python build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.9" + python: "3.12" jobs: pre_build: - python ./scripts/_locale.py --compile diff --git a/DOCKER_README.md b/DOCKER_README.md index bc66318f0..222e25abd 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,6 +1,8 @@ -### lizardbyte/retroarcher +# Docker -#### Using docker run +## lizardbyte/retroarcher + +### Using docker run Create and run the container (substitute your ``): ```bash @@ -28,7 +30,7 @@ docker pull lizardbyte/retroarcher docker run -d ... ``` -#### Using docker-compose +### Using docker-compose Create a `docker-compose.yml` file with the following contents (substitute your ``): @@ -63,7 +65,7 @@ docker-compose pull docker-compose up -d ``` -#### Parameters +### Parameters You must substitute the `` with your own settings. Parameters are split into two halves separated by a colon. The left side represents the host and the right side the @@ -83,7 +85,7 @@ Therefore `-p 9696:9696` would expose port `9696` from inside the container to b | `-e PGID=` | Group ID | `1001` | False | | `-e TZ=` | Lookup TZ value [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | `America/New_York` | True | -#### User / Group Identifiers: +### User / Group Identifiers: When using data volumes (-v flags) permissions issues can arise between the host OS and the container. To avoid this issue you can specify the user PUID and group PGID. Ensure the data volume directory on the host is owned by the same diff --git a/Dockerfile b/Dockerfile index 6ea14975f..211ae1b9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,29 @@ # artifacts: false -# platforms: linux/386,linux/amd64 -FROM python:3.9.6-slim-bullseye as retroarcher-base +# platforms: linux/amd64,linux/arm64/v8 +FROM python:3.12-slim-bookworm AS base -FROM retroarcher-base as retroarcher-build +FROM base AS build + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] # install build dependencies -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends \ - build-essential \ - nodejs \ - npm \ - pkg-config \ - libopenblas-dev \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +RUN <<_DEPS +#!/bin/bash +set -e + +dependencies=( + "build-essential" + "libjpeg-dev" # pillow + "npm" # web dependencies + "pkg-config" + "libopenblas-dev" + "zlib1g-dev" # pillow +) +apt-get update -y +apt-get install -y --no-install-recommends "${dependencies[@]}" +apt-get clean +rm -rf /var/lib/apt/lists/* +_DEPS # python virtualenv RUN python -m venv /opt/venv @@ -25,44 +35,72 @@ WORKDIR /build COPY . . # setup python requirements -RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel && \ - python -m pip install --no-cache-dir -r requirements.txt +RUN <<_REQUIREMENTS +#!/bin/bash +set -e +python -m pip install --no-cache-dir --upgrade pip setuptools wheel +python -m pip install --no-cache-dir -r requirements.txt +_REQUIREMENTS # compile locales RUN python scripts/_locale.py --compile # setup npm and dependencies -RUN npm install && \ - mv -f ./node_modules/ ./web/ +RUN <<_NPM +#!/bin/bash +set -e +npm install +mv -f ./node_modules/ ./web/ +_NPM # compile docs WORKDIR /build/docs RUN sphinx-build -M html source build -FROM retroarcher-base as retroarcher +FROM base AS app # copy app from builder -COPY --from=retroarcher-build /build/ /app/ +COPY --from=build /build/ /app/ # copy python venv -COPY --from=retroarcher-build /opt/venv/ /opt/venv/ +COPY --from=build /opt/venv/ /opt/venv/ # use the venv ENV PATH="/opt/venv/bin:$PATH" # site-packages are in /opt/venv/lib/python/site-packages/ # setup remaining env variables ENV RETROARCHER_DOCKER=True -ENV TZ=UTC + +# network setup +EXPOSE 9696 # setup user -RUN groupadd -g 1000 retroarcher && \ - useradd -u 1000 -g 1000 retroarcher +ARG PGID=1000 +ENV PGID=${PGID} +ARG PUID=1000 +ENV PUID=${PUID} +ENV TZ="UTC" +ARG UNAME=lizard +ENV UNAME=${UNAME} -# create config directory -RUN mkdir -p /config +ENV HOME=/home/$UNAME + +# setup user +RUN <<_SETUP_USER +#!/bin/bash +set -e +groupadd -f -g "${PGID}" "${UNAME}" +useradd -lm -d ${HOME} -s /bin/bash -g "${PGID}" -u "${PUID}" "${UNAME}" +mkdir -p ${HOME}/.config/retroarcher +ln -s ${HOME}/.config/retroarcher /config +chown -R ${UNAME} ${HOME} +_SETUP_USER + +# mounts VOLUME /config -CMD ["python", "retroarcher.py"] +USER ${UNAME} +WORKDIR ${HOME} -EXPOSE 9696 -HEALTHCHECK --start-period=90s CMD python retroarcher.py --docker_healthcheck || exit 1 +ENTRYPOINT ["python", "./src/retroarcher.py"] +HEALTHCHECK --start-period=90s CMD python ./src/retroarcher.py --docker_healthcheck || exit 1 diff --git a/crowdin.yml b/crowdin.yml index 0be504ba7..50c63a68f 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -2,6 +2,7 @@ "base_path": "." "base_url": "https://api.crowdin.com" # optional (for Crowdin Enterprise only) "preserve_hierarchy": false # flatten tree on crowdin +"pull_request_title": "chore(l10n): update translations" "pull_request_labels": [ "crowdin", "l10n" diff --git a/docs/source/about/docker.rst b/docs/source/about/docker.rst index de5d27115..4f43056aa 100644 --- a/docs/source/about/docker.rst +++ b/docs/source/about/docker.rst @@ -1,4 +1,2 @@ -Docker ------- - -.. mdinclude:: ../../../DOCKER_README.md +.. include:: ../../../DOCKER_README.md + :parser: myst_parser.docutils_ diff --git a/docs/source/conf.py b/docs/source/conf.py index 343d02862..f87163d34 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,6 +6,8 @@ # standard imports from datetime import datetime +import os +import sys # -- Path setup -------------------------------------------------------------- @@ -13,17 +15,17 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -import os -import sys script_dir = os.path.dirname(os.path.abspath(__file__)) # the directory of this file source_dir = os.path.dirname(script_dir) # the source folder directory root_dir = os.path.dirname(source_dir) # the root folder directory +src_dir = os.path.join(root_dir, 'src') # the src folder directory try: - sys.path.insert(0, root_dir) - from pyra import definitions # put this in a try/except to prevent flake8 warning -except Exception: + sys.path.insert(0, src_dir) + from common import definitions # put this in a try/except to prevent flake8 warning +except Exception as e: + print(f"Unable to import definitions from {root_dir}: {e}") sys.exit(1) # -- Project information ----------------------------------------------------- @@ -42,10 +44,11 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'm2r2', # enable markdown files + 'myst_parser', # enable markdown files 'numpydoc', # this automatically loads `sphinx.ext.autosummary` as well 'sphinx.ext.autodoc', # autodocument modules 'sphinx.ext.autosectionlabel', + 'sphinx.ext.intersphinx', # link to other projects' documentation 'sphinx.ext.todo', # enable to-do sections 'sphinx.ext.viewcode' # add links to view source code ] @@ -59,13 +62,16 @@ exclude_patterns = ['toc.rst'] # Extensions to include. -source_suffix = ['.rst', '.md'] +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} # -- Options for HTML output ------------------------------------------------- # images -html_favicon = os.path.join(definitions.Paths().ROOT_DIR, 'web', 'images', 'retroarcher.ico') +html_favicon = os.path.join(definitions.Paths().ROOT_DIR, 'web', 'images', 'favicon.ico') html_logo = os.path.join(definitions.Paths().ROOT_DIR, 'web', 'images', 'logo-circle.png') # Add any paths that contain custom static files (such as style sheets) here, @@ -102,3 +108,13 @@ # disable epub mimetype warnings # https://github.com/readthedocs/readthedocs.org/blob/eadf6ac6dc6abc760a91e1cb147cc3c5f37d1ea8/docs/conf.py#L235-L236 suppress_warnings = ["epub.unknown_project_files"] + +python_version = f'{sys.version_info.major}.{sys.version_info.minor}' + +intersphinx_mapping = { + 'python': ('https://docs.python.org/{}/'.format(python_version), None), +} + +numpydoc_show_class_members = True +numpydoc_show_inherited_class_members = False +numpydoc_xref_param_type = True diff --git a/docs/source/contributing/localization.rst b/docs/source/contributing/localization.rst index 56c5fc4a0..554e6664b 100644 --- a/docs/source/contributing/localization.rst +++ b/docs/source/contributing/localization.rst @@ -2,7 +2,7 @@ Localization ============ RetroArcher is being localized into various languages. The default language is `en` (English). -.. image:: https://app.lizardbyte.dev/uno/crowdin/LizardByte_graph.svg +.. image:: https://app.lizardbyte.dev/dashboard/crowdin/LizardByte_graph.svg CrowdIn ------- @@ -43,7 +43,7 @@ situations. For example the system tray icon is user interfacing and therefore s - In order for strings to be extracted from python code, the following lines must be added. .. code-block:: python - from pyra import locales + from common import locales _ = locales.get_text() - Wrap the string to be extracted in a function as shown. @@ -76,8 +76,7 @@ any of the following paths are modified. .. code-block:: yaml - - 'retroarcher.py' - - 'pyra/**.py' + - 'src/**.py' - 'web/templates/**' When testing locally it may be desirable to manually extract, initialize, update, and compile strings. diff --git a/docs/source/pyra_docs/config.rst b/docs/source/pyra_docs/config.rst deleted file mode 100644 index 27d12371a..000000000 --- a/docs/source/pyra_docs/config.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.config` ----------------------- -.. automodule:: pyra.config - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/hardware.rst b/docs/source/pyra_docs/hardware.rst deleted file mode 100644 index 7c5a1c8a6..000000000 --- a/docs/source/pyra_docs/hardware.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.hardware` ---------------------------- -.. automodule:: pyra.hardware - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/helpers.rst b/docs/source/pyra_docs/helpers.rst deleted file mode 100644 index a5f116a8d..000000000 --- a/docs/source/pyra_docs/helpers.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.helpers` ------------------------ -.. automodule:: pyra.helpers - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/locales.rst b/docs/source/pyra_docs/locales.rst deleted file mode 100644 index db2754c90..000000000 --- a/docs/source/pyra_docs/locales.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.locales` ------------------------ -.. automodule:: pyra.locales - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/logger.rst b/docs/source/pyra_docs/logger.rst deleted file mode 100644 index c4c38a92b..000000000 --- a/docs/source/pyra_docs/logger.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.logger` ----------------------- -.. automodule:: pyra.logger - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/threads.rst b/docs/source/pyra_docs/threads.rst deleted file mode 100644 index 0d29f9d2f..000000000 --- a/docs/source/pyra_docs/threads.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.threads` ------------------------ -.. automodule:: pyra.threads - :members: - :show-inheritance: diff --git a/docs/source/pyra_docs/webapp.rst b/docs/source/pyra_docs/webapp.rst deleted file mode 100644 index c8f3dcdf2..000000000 --- a/docs/source/pyra_docs/webapp.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. include:: ../global.rst - -:modname:`pyra.webapp` ----------------------- -.. automodule:: pyra.webapp - :members: - :show-inheritance: diff --git a/docs/source/src/common/common.rst b/docs/source/src/common/common.rst new file mode 100644 index 000000000..0f301a6a3 --- /dev/null +++ b/docs/source/src/common/common.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.__init__` +-------------------------- +.. automodule:: common + :members: + :show-inheritance: diff --git a/docs/source/pyra_docs/pyra.rst b/docs/source/src/common/config.rst similarity index 62% rename from docs/source/pyra_docs/pyra.rst rename to docs/source/src/common/config.rst index 1355c9f68..30f6b6c99 100644 --- a/docs/source/pyra_docs/pyra.rst +++ b/docs/source/src/common/config.rst @@ -1,7 +1,7 @@ .. include:: ../global.rst -:modname:`pyra.__init__` +:modname:`common.config` ------------------------ -.. automodule:: pyra +.. automodule:: common.config :members: :show-inheritance: diff --git a/docs/source/src/common/definitions.rst b/docs/source/src/common/definitions.rst new file mode 100644 index 000000000..86c964650 --- /dev/null +++ b/docs/source/src/common/definitions.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.definitions` +----------------------------- +.. automodule:: common.definitions + :members: + :show-inheritance: diff --git a/docs/source/src/common/hardware.rst b/docs/source/src/common/hardware.rst new file mode 100644 index 000000000..66e9c33a1 --- /dev/null +++ b/docs/source/src/common/hardware.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.hardware` +-------------------------- +.. automodule:: common.hardware + :members: + :show-inheritance: diff --git a/docs/source/pyra_docs/tray_icon.rst b/docs/source/src/common/helpers.rst similarity index 61% rename from docs/source/pyra_docs/tray_icon.rst rename to docs/source/src/common/helpers.rst index e4fc34c28..9f08d062f 100644 --- a/docs/source/pyra_docs/tray_icon.rst +++ b/docs/source/src/common/helpers.rst @@ -1,7 +1,7 @@ .. include:: ../global.rst -:modname:`pyra.tray_icon` +:modname:`common.helpers` ------------------------- -.. automodule:: pyra.tray_icon +.. automodule:: common.helpers :members: :show-inheritance: diff --git a/docs/source/src/common/locales.rst b/docs/source/src/common/locales.rst new file mode 100644 index 000000000..f6a1776ce --- /dev/null +++ b/docs/source/src/common/locales.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.locales` +------------------------- +.. automodule:: common.locales + :members: + :show-inheritance: diff --git a/docs/source/src/common/logger.rst b/docs/source/src/common/logger.rst new file mode 100644 index 000000000..5451e66a2 --- /dev/null +++ b/docs/source/src/common/logger.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.logger` +------------------------ +.. automodule:: common.logger + :members: + :show-inheritance: diff --git a/docs/source/src/common/threads.rst b/docs/source/src/common/threads.rst new file mode 100644 index 000000000..dda872f9e --- /dev/null +++ b/docs/source/src/common/threads.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.threads` +------------------------- +.. automodule:: common.threads + :members: + :show-inheritance: diff --git a/docs/source/pyra_docs/definitions.rst b/docs/source/src/common/tray_icon.rst similarity index 60% rename from docs/source/pyra_docs/definitions.rst rename to docs/source/src/common/tray_icon.rst index 7902f1ab2..ea83144d3 100644 --- a/docs/source/pyra_docs/definitions.rst +++ b/docs/source/src/common/tray_icon.rst @@ -1,7 +1,7 @@ .. include:: ../global.rst -:modname:`pyra.definitions` +:modname:`common.tray_icon` --------------------------- -.. automodule:: pyra.definitions +.. automodule:: common.tray_icon :members: :show-inheritance: diff --git a/docs/source/src/common/webapp.rst b/docs/source/src/common/webapp.rst new file mode 100644 index 000000000..68ce33887 --- /dev/null +++ b/docs/source/src/common/webapp.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.webapp` +------------------------ +.. automodule:: common.webapp + :members: + :show-inheritance: diff --git a/docs/source/global.rst b/docs/source/src/global.rst similarity index 100% rename from docs/source/global.rst rename to docs/source/src/global.rst diff --git a/docs/source/main/retroarcher.rst b/docs/source/src/retroarcher.rst similarity index 80% rename from docs/source/main/retroarcher.rst rename to docs/source/src/retroarcher.rst index 805365900..35fb9b9c2 100644 --- a/docs/source/main/retroarcher.rst +++ b/docs/source/src/retroarcher.rst @@ -1,4 +1,4 @@ -.. include:: ../global.rst +.. include:: global.rst :modname:`retroarcher` ---------------------- diff --git a/docs/source/toc.rst b/docs/source/toc.rst index c03f6a46a..5e0089e6c 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -19,17 +19,17 @@ .. toctree:: :maxdepth: 0 - :caption: Code + :caption: Source Code :titlesonly: - main/retroarcher - pyra_docs/pyra - pyra_docs/config - pyra_docs/definitions - pyra_docs/hardware - pyra_docs/helpers - pyra_docs/locales - pyra_docs/logger - pyra_docs/threads - pyra_docs/tray_icon - pyra_docs/webapp + src/retroarcher + src/common/common + src/common/config + src/common/definitions + src/common/hardware + src/common/helpers + src/common/locales + src/common/logger + src/common/threads + src/common/tray_icon + src/common/webapp diff --git a/requirements.txt b/requirements.txt index 593b70cb1..ffce9347b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,22 @@ -Babel==2.16.0 +babel==2.16.0 configobj==5.0.9 -Flask==3.0.3 -Flask-Babel==4.0.0 +cryptography==43.0.0 +flask==3.0.3 +flask-babel==4.0.0 +flask-wtf==1.2.1 furo==2024.8.6 -GPUtil==1.4.0 -IPy==1.01 -m2r2==0.3.3.post2 +gputil==1.4.0 +ipy==1.01 +myst-parser==4.0.0 numexpr==2.10.1 numpydoc==1.7.0 -Pillow==9.5.0 +pillow==9.5.0 +polib==1.2.0 psutil==6.0.0 pyadl==0.1 pyamdgpuinfo==2.1.6; sys_platform == 'Linux' +pyopenssl==24.2.1 pystray==0.19.5 requests==2.32.3 -Sphinx==7.2.6 +sphinx==7.2.6 +werkzeug==3.0.3 diff --git a/scripts/_locale.py b/scripts/_locale.py index cc80b0f81..153ba9836 100644 --- a/scripts/_locale.py +++ b/scripts/_locale.py @@ -1,6 +1,5 @@ """ -.. - _locale.py +scripts/_locale.py Functions related to building, initializing, updating, and compiling localization translations. """ @@ -15,16 +14,21 @@ root_dir = os.path.dirname(script_dir) locale_dir = os.path.join(root_dir, 'locale') -# retroarcher target locales +# target locales target_locales = [ - 'de', # Deutsch + 'de', # German 'en', # English 'en_GB', # English (United Kingdom) 'en_US', # English (United States) - 'es', # español - 'fr', # français - 'it', # italiano - 'ru', # русский + 'es', # Spanish + 'fr', # French + 'it', # Italian + 'ja', # Japanese + 'pt', # Portuguese + 'ru', # Russian + 'sv', # Swedish + 'tr', # Turkish + 'zh', # Chinese (Simplified) ] @@ -41,9 +45,8 @@ def babel_extract(): f'--project={project_name}', '--version=v0', '--add-comments=NOTE', - './retroarcher.py', - './pyra', - './web' + './src', + './web', ] print(commands) diff --git a/scripts/_run_tests.py b/scripts/_run_tests.py index 086e751ff..b0bca1cdd 100644 --- a/scripts/_run_tests.py +++ b/scripts/_run_tests.py @@ -1,6 +1,5 @@ """ -.. - _run_tests.py +scripts/_run_tests.py This is not intended to be run by the end user, but only to supplement the `python_tests.yml` github action. diff --git a/scripts/build.py b/scripts/build.py index 773b010ab..599782030 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,6 +1,5 @@ """ -.. - build.py +scripts/build.py Creates spec and builds binaries for RetroArcher. """ @@ -14,14 +13,14 @@ def build(): """Sets arguments for pyinstaller, creates spec, and builds binaries.""" pyinstaller_args = [ - 'retroarcher.py', + './src/retroarcher.py', '--onefile', '--noconfirm', '--paths=./', '--add-data=docs:docs', '--add-data=web:web', '--add-data=locale:locale', - '--icon=./web/images/retroarcher.ico' + '--icon=./web/images/favicon.ico' ] if sys.platform.lower() == 'win32': # windows @@ -29,10 +28,8 @@ def build(): pyinstaller_args.append('--splash=./web/images/logo-circle.png') # fix args for windows - arg_count = 0 - for arg in pyinstaller_args: - pyinstaller_args[arg_count] = arg.replace(':', ';') - arg_count += 1 + for index, arg in enumerate(pyinstaller_args): + pyinstaller_args[index] = arg.replace(':', ';') elif sys.platform.lower() == 'darwin': # macOS pyinstaller_args.append('--console') pyinstaller_args.append('--osx-bundle-identifier=dev.lizardbyte.retroarcher') diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyra/__init__.py b/src/common/__init__.py similarity index 94% rename from pyra/__init__.py rename to src/common/__init__.py index e1473cc7b..3b615c213 100644 --- a/pyra/__init__.py +++ b/src/common/__init__.py @@ -1,12 +1,8 @@ """ -.. - __init__.py +src/common/__init__.py Responsible for initialization of RetroArcher. """ -# future imports -from __future__ import annotations - # standard imports import os import subprocess @@ -15,10 +11,10 @@ from typing import Union # local imports -from pyra import config -from pyra import definitions -from pyra import helpers -from pyra import logger +from common import config +from common import definitions +from common import helpers +from common import logger # get logger log = logger.get_logger(name=__name__) @@ -121,7 +117,7 @@ def stop(exit_code: Union[int, str] = 0, restart: bool = False): >>> stop(exit_code=0, restart=False) """ # stop the tray icon - from pyra.tray_icon import tray_end + from common.tray_icon import tray_end try: tray_end() except AttributeError: diff --git a/pyra/config.py b/src/common/config.py similarity index 75% rename from pyra/config.py rename to src/common/config.py index c01b67da8..fc965f5d1 100644 --- a/pyra/config.py +++ b/src/common/config.py @@ -1,10 +1,11 @@ """ -.. - config.py +src/common/config.py Responsible for config related functions. """ # standard imports +import base64 +import copy import sys from typing import Optional, List @@ -13,9 +14,9 @@ from validate import Validator, ValidateError # local imports -from pyra import definitions -from pyra import logger -from pyra import locales +from common import definitions +from common import logger +from common import locales # get log log = logger.get_logger(name=__name__) @@ -47,14 +48,14 @@ def on_change_tray_toggle() -> bool: See Also -------- - pyra.tray_icon.tray_toggle : ``on_change_tray_toggle`` is an alias of this function. + common.tray_icon.tray_toggle : ``on_change_tray_toggle`` is an alias of this function. Examples -------- >>> on_change_tray_toggle() True """ - from pyra import tray_icon + from common import tray_icon return tray_icon.tray_toggle() @@ -108,12 +109,34 @@ def on_change_tray_toggle() -> bool: description=_('The localization setting to use.'), default='en', options=[ + 'de', 'en', + 'en_GB', + 'en_US', 'es', + 'fr', + 'it', + 'ja', + 'pt', + 'ru', + 'sv', + 'tr', + 'zh', ], option_names=[ + f'German ({_("German")})', f'English ({_("English")})', - f'Español ({_("Spanish")})', + f'English (Great Britain) ({_("English (Great Britain)")})', + f'English (United States) ({_("English (United States)")})', + f'Spanish ({_("Spanish")})', + f'French ({_("French")})', + f'Italian ({_("Italian")})', + f'Japanese ({_("Japanese")})', + f'Portuguese ({_("Portuguese")})', + f'Russian ({_("Russian")})', + f'Swedish ({_("Swedish")})', + f'Turkish ({_("Turkish")})', + f'Chinese (Simplified) ({_("Chinese (Simplified)")})', ], refresh=True, extra_class='col-lg-6', @@ -192,6 +215,13 @@ def on_change_tray_toggle() -> bool: description=_('Todo: The base URL of the web server. Used for reverse proxies.'), extra_class='col-lg-6', ), + SSL=dict( + type='boolean', + name=_('SSL'), + default=True, + description=_('Run the web server with HTTPS. ' + 'Disabling this can be a security risk, do so at your own risk.'), + ), ), User_Interface=dict( type='section', @@ -221,6 +251,115 @@ def on_change_tray_toggle() -> bool: ) +def is_masked_field(section: str, key: str) -> bool: + """ + Check if a field is masked. + + This function will check if a field is masked in the config spec dictionary. + + Parameters + ---------- + section : str + The section of the config. + key : str + The key of the config field. + + Returns + ------- + bool + True if the field is masked, otherwise False. + + Examples + -------- + >>> is_masked_field(section='General', key='API_KEY') + True + """ + return _CONFIG_SPEC_DICT.get(section, {}).get(key, {}).get('mask', False) + + +def encode_value(value: str) -> str: + """ + Encode a value using base64. + + This function will encode a value using base64. + + Parameters + ---------- + value : str + The value to encode. + + Returns + ------- + str + The encoded value. + + Examples + -------- + >>> encode_value('some text') + 'c29tZSB0ZXh0' + """ + return base64.b64encode(value.encode('utf-8')).decode('utf-8') + + +def decode_value(value: str) -> str: + """ + Decode a base64 encoded value. + + This function will decode a base64 encoded value. + + Parameters + ---------- + value : str + The value to decode. + + Returns + ------- + str + The decoded value. If the value cannot be decoded, an empty string is returned. + + Examples + -------- + >>> decode_value('c29tZSB0ZXh0') + 'some text' + """ + try: + return base64.b64decode(value.encode('utf-8')).decode('utf-8') + except Exception as e: + log.error(msg=f"Unable to decode value: {e}") + return '' + + +def decode_config(config: ConfigObj) -> dict: + """ + Decode masked fields in the config. + + This function will create a decoded copy of the config object, and decode any masked fields. + + Parameters + ---------- + config : ConfigObj + The config object to decode. + + Returns + ------- + dict + A decoded copy of the config object. + + Examples + -------- + >>> config_object = create_config(config_file='config.ini') + >>> decode_config(config=config_object) + {...} + """ + _config = copy.deepcopy(config) # we need to do a deepcopy to avoid modifying the original config + + for section, options in _config.items(): + for key, value in options.items(): + if is_masked_field(section=section, key=key): + _config[section][key] = decode_value(value) + return _config + + def convert_config(d: dict = _CONFIG_SPEC_DICT, _config_spec: Optional[List] = None) -> List: """ Convert a config spec dictionary to a config spec list. @@ -255,6 +394,10 @@ def convert_config(d: dict = _CONFIG_SPEC_DICT, _config_spec: Optional[List] = N except TypeError: pass else: + # if a default value is not set, then set it to None + if 'default' not in v: + v['default'] = None + checks = ['min', 'max', 'options', 'default'] check_value = '' @@ -332,7 +475,7 @@ def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> Co encoding='UTF-8', list_values=True, stringify=True, - write_empty_values=False + write_empty_values=False, ) config_valid = validate_config(config=config) @@ -348,7 +491,7 @@ def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> Co encoding='UTF-8', list_values=True, stringify=True, - write_empty_values=False + write_empty_values=False, ) user_config_valid = validate_config(config=user_config) if not user_config_valid: @@ -376,7 +519,7 @@ def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> Co validate_config(config=config) config.filename = config_file - config.write() # write the config file + save_config(config=config) if config_spec == _CONFIG_SPEC_DICT: # set CONFIG dictionary global CONFIG diff --git a/src/common/crypto.py b/src/common/crypto.py new file mode 100644 index 000000000..21e7a8caa --- /dev/null +++ b/src/common/crypto.py @@ -0,0 +1,72 @@ +# standard imports +import os + +# lib imports +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption +from datetime import datetime, timedelta, UTC + +# local imports +from common import definitions +from common import logger + +log = logger.get_logger(name=__name__) + +CERT_FILE = os.path.join(definitions.Paths.CONFIG_DIR, "cert.pem") +KEY_FILE = os.path.join(definitions.Paths.CONFIG_DIR, "key.pem") + + +def check_expiration(cert_path: str) -> int: + with open(cert_path, "rb") as cert_file: + cert_data = cert_file.read() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + expiry_date = cert.not_valid_after_utc + return (expiry_date - datetime.now(UTC)).days + + +def generate_certificate(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + subject = issuer = x509.Name([ + x509.NameAttribute(x509.NameOID.COMMON_NAME, u"localhost"), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.now(UTC) + ).not_valid_after( + datetime.now(UTC) + timedelta(days=365) + ).sign(private_key, hashes.SHA256()) + + with open(KEY_FILE, "wb") as f: + f.write(private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + )) + + with open(CERT_FILE, "wb") as f: + f.write(cert.public_bytes(Encoding.PEM)) + + +def initialize_certificate() -> tuple[str, str]: + log.info("Initializing SSL certificate") + if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE): + cert_expires_in = check_expiration(CERT_FILE) + log.info(f"Certificate expires in {cert_expires_in} days.") + if cert_expires_in >= 90: + return CERT_FILE, KEY_FILE + log.info("Generating new certificate") + generate_certificate() + return CERT_FILE, KEY_FILE diff --git a/pyra/definitions.py b/src/common/definitions.py similarity index 87% rename from pyra/definitions.py rename to src/common/definitions.py index e0fc24288..fc43b5a6e 100644 --- a/pyra/definitions.py +++ b/src/common/definitions.py @@ -1,6 +1,5 @@ """ -.. - definitions.py +src/common/definitions.py Contains classes with attributes to common definitions (paths and filenames). """ @@ -124,8 +123,10 @@ class Paths: The purpose of this class is to ensure consistency when using these paths. - PYRA_DIR : str - The directory containing the retroarcher python files. + COMMON_DIR : str + The directory containing the common python files. + SRC_DIR : str + The directory containing the application python files ROOT_DIR : str The root directory of the application. This is where the source files exist. DATA_DIR : str @@ -139,20 +140,24 @@ class Paths: Examples -------- - >>> Paths.logs + >>> Paths.LOG_DIR '.../logs' """ - PYRA_DIR = os.path.dirname(os.path.abspath(__file__)) - ROOT_DIR = os.path.dirname(PYRA_DIR) + COMMON_DIR = os.path.dirname(os.path.abspath(__file__)) + SRC_DIR = os.path.dirname(COMMON_DIR) + ROOT_DIR = os.path.dirname(SRC_DIR) DATA_DIR = ROOT_DIR - BINARY_PATH = os.path.abspath(os.path.join(DATA_DIR, 'retroarcher.py')) + BINARY_PATH = os.path.abspath(os.path.join(SRC_DIR, 'retroarcher.py')) if Modes.FROZEN: # pyinstaller build DATA_DIR = os.path.dirname(sys.executable) BINARY_PATH = os.path.abspath(sys.executable) if Modes.DOCKER: # docker install DATA_DIR = '/config' # overwrite the value that was already set + CONFIG_DIR = DATA_DIR + else: + CONFIG_DIR = os.path.join(DATA_DIR, 'config') DOCS_DIR = os.path.join(ROOT_DIR, 'docs', 'build', 'html') LOCALE_DIR = os.path.join(ROOT_DIR, 'locale') - LOG_DIR = os.path.join(DATA_DIR, 'logs') + LOG_DIR = os.path.join(CONFIG_DIR, 'logs') diff --git a/pyra/hardware.py b/src/common/hardware.py similarity index 98% rename from pyra/hardware.py rename to src/common/hardware.py index 44b885d78..bedf099c5 100644 --- a/pyra/hardware.py +++ b/src/common/hardware.py @@ -1,6 +1,5 @@ """ -.. - hardware.py +src/common/hardware.py Functions related to the dashboard viewer. """ @@ -12,10 +11,10 @@ import psutil # local imports -from pyra import definitions -from pyra import helpers -from pyra import locales -from pyra import logger +from common import definitions +from common import helpers +from common import locales +from common import logger try: cpu_name = cpuinfo.cpu.info[0]['ProcessorNameString'].strip() @@ -335,7 +334,7 @@ def chart_data() -> dict: See Also -------- - pyra.webapp.callback_dashboard : A callback called by javascript to get this data. + common.webapp.callback_dashboard : A callback called by javascript to get this data. Examples -------- diff --git a/pyra/helpers.py b/src/common/helpers.py similarity index 93% rename from pyra/helpers.py rename to src/common/helpers.py index 086494625..c1028face 100644 --- a/pyra/helpers.py +++ b/src/common/helpers.py @@ -1,12 +1,8 @@ """ -.. - helpers.py +src/common/helpers.py Many reusable helper functions. """ -# future imports -from __future__ import annotations - # standard imports import datetime import ipaddress @@ -61,16 +57,15 @@ def check_folder_writable(fallback: str, name: str, folder: Optional[str] = None if not folder: folder = fallback - if not os.path.isdir(s=folder): # if directory doesn't exist - try: - os.makedirs(name=folder) # try to make the directory - except OSError as e: - log.error(msg=f"Could not create {name} dir '{folder}': {e}") - if fallback and folder != fallback: - log.warning(msg=f"Falling back to {name} dir '{fallback}'") - return check_folder_writable(folder=None, fallback=fallback, name=name) - else: - return folder, None + try: + os.makedirs(name=folder) # try to make the directory + except OSError as e: + log.error(msg=f"Could not create {name} dir '{folder}': {e}") + if fallback and folder != fallback: + log.warning(msg=f"Falling back to {name} dir '{fallback}'") + return check_folder_writable(folder=None, fallback=fallback, name=name) + else: + return folder, None if not os.access(path=folder, mode=os.W_OK): log.error(msg=f"Cannot write to {name} dir '{folder}'") diff --git a/pyra/locales.py b/src/common/locales.py similarity index 97% rename from pyra/locales.py rename to src/common/locales.py index 0e97027e3..34f00ab80 100644 --- a/pyra/locales.py +++ b/src/common/locales.py @@ -1,6 +1,5 @@ """ -.. - locales.py +src/common/locales.py Functions related to localization. @@ -27,9 +26,9 @@ from babel import localedata # local imports -from pyra import config -from pyra.definitions import Paths -from pyra import logger +from common import config +from common.definitions import Paths +from common import logger default_domain = 'retroarcher' default_locale = 'en' diff --git a/pyra/logger.py b/src/common/logger.py similarity index 95% rename from pyra/logger.py rename to src/common/logger.py index 6c1601b30..1619e5260 100644 --- a/pyra/logger.py +++ b/src/common/logger.py @@ -1,12 +1,8 @@ """ -.. - logger.py +src/common/logger.py Responsible for logging related functions. """ -# future imports -from __future__ import annotations - # standard imports import contextlib import errno @@ -25,12 +21,12 @@ from configobj import ConfigObj # local imports -import pyra -from pyra import definitions -from pyra import helpers +import common +from common import definitions +from common import helpers # These settings are for file logging only -py_name = 'pyra' +py_name = 'common' MAX_SIZE = 5000000 # 5 MB MAX_FILES = 5 @@ -67,7 +63,7 @@ def blacklist_config(config: ConfigObj): Examples -------- - >>> config_object = pyra.config.create_config(config_file='config.ini') + >>> config_object = common.config.create_config(config_file='config.ini') >>> blacklist_config(config=config_object) """ blacklist = set() @@ -102,7 +98,7 @@ class NoThreadFilter(logging.Filter): Examples -------- >>> NoThreadFilter('main') - + """ def __init__(self, threadName): @@ -134,7 +130,7 @@ def filter(self, record) -> bool: >>> NoThreadFilter('main').filter(record=NoThreadFilter('main')) False """ - return not record.threadName == self.threadName + return record.threadName != self.threadName # Taken from Hellowlol/HTPC-Manager @@ -152,7 +148,7 @@ class BlacklistFilter(logging.Filter): Examples -------- >>> BlacklistFilter() - + """ def __init__(self): @@ -223,7 +219,7 @@ class RegexFilter(logging.Filter): Examples -------- >>> RegexFilter() - + """ def __init__(self): @@ -301,7 +297,7 @@ class PublicIPFilter(RegexFilter): Examples -------- >>> PublicIPFilter() - + """ def __init__(self): @@ -358,7 +354,7 @@ class EmailFilter(RegexFilter): Examples -------- >>> EmailFilter() - + """ def __init__(self): @@ -414,7 +410,7 @@ class PlexTokenFilter(RegexFilter): Examples -------- >>> PlexTokenFilter() - + """ def __init__(self): @@ -453,7 +449,7 @@ def listener(logger: logging.Logger): """ Create a QueueListener. - Wrapper that create a QueueListener, starts it and automatically stops it. + Wrapper that creates a QueueListener, starts it and automatically stops it. To be used in a with statement in the main process, for multiprocessing. Parameters @@ -461,6 +457,10 @@ def listener(logger: logging.Logger): logger : logging.Logger The logger object. + Yields + ------ + None + Examples -------- >>> logger = get_logger(name='retroarcher') @@ -568,7 +568,7 @@ def setup_loggers(): """ loggers_list = [py_name, 'werkzeug'] - submodules = pkgutil.iter_modules(pyra.__path__) + submodules = pkgutil.iter_modules(common.__path__) for submodule in submodules: loggers_list.append(f'{py_name}.{submodule[1]}') @@ -613,7 +613,7 @@ def init_logger(log_name: str) -> logging.Logger: # Configure the logger to accept all messages logger.propagate = False - logger.setLevel(logging.DEBUG if pyra.DEBUG else logging.INFO) + logger.setLevel(logging.DEBUG if common.DEBUG else logging.INFO) # Setup file logger file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', @@ -631,7 +631,7 @@ def init_logger(log_name: str) -> logging.Logger: logger.addHandler(file_handler) # Setup console logger - if not pyra.QUIET: + if not common.QUIET: console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%Y-%m-%d %H:%M:%S') console_handler = logging.StreamHandler() @@ -643,7 +643,7 @@ def init_logger(log_name: str) -> logging.Logger: # Add filters to log handlers # Only add filters after the config file has been initialized # Nothing prior to initialization should contain sensitive information - if not pyra.DEV and pyra.CONFIG: + if not common.DEV and common.CONFIG: log_handlers = logger.handlers for handler in log_handlers: handler.addFilter(BlacklistFilter()) @@ -652,7 +652,7 @@ def init_logger(log_name: str) -> logging.Logger: handler.addFilter(PlexTokenFilter()) # Install exception hooks - if log_name == py_name: # all tracebacks go to 'pyra.log' + if log_name == py_name: # all tracebacks go to 'common.log' _init_hooks(logger) # replace warn diff --git a/pyra/threads.py b/src/common/threads.py similarity index 82% rename from pyra/threads.py rename to src/common/threads.py index 10dd23785..71be38f72 100644 --- a/pyra/threads.py +++ b/src/common/threads.py @@ -1,6 +1,5 @@ """ -.. - threads.py +src/common/threads.py Functions related to threading. @@ -11,15 +10,15 @@ Examples -------- ->>> from pyra import config, threads, tray_icon +>>> from common import config, threads, tray_icon >>> config_object = config.create_config(config_file='config.ini') >>> tray_icon.icon = tray_icon.tray_initialize() >>> threads.run_in_thread(target=tray_icon.tray_run, name='pystray', daemon=True).start() ->>> from pyra import config, threads, webapp +>>> from common import config, threads, webapp >>> config_object = config.create_config(config_file='config.ini') >>> threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() - * Serving Flask app 'pyra.webapp' (lazy loading) + * Serving Flask app 'common.webapp' (lazy loading) ... * Running on http://.../ (Press CTRL+C to quit) """ diff --git a/pyra/tray_icon.py b/src/common/tray_icon.py similarity index 94% rename from pyra/tray_icon.py rename to src/common/tray_icon.py index 6b7baee68..b03e69a86 100644 --- a/pyra/tray_icon.py +++ b/src/common/tray_icon.py @@ -1,6 +1,5 @@ """ -.. - tray_icon.py +src/common/tray_icon.py Responsible for system tray icon and related functions. """ @@ -12,13 +11,14 @@ from PIL import Image # local imports -import pyra -from pyra import config -from pyra import definitions -from pyra import helpers -from pyra import locales -from pyra import logger -from pyra import threads +import common +from common import config +from common import definitions +from common import helpers +from common import locales +from common import logger +from common import threads +from common import webapp # setup _ = locales.get_text() @@ -68,7 +68,7 @@ def tray_initialize() -> Union[Icon, bool]: tray_icon = Icon(name='retroarcher') tray_icon.title = definitions.Names.name - image = Image.open(os.path.join(definitions.Paths.ROOT_DIR, 'web', 'images', 'retroarcher.ico')) + image = Image.open(os.path.join(definitions.Paths.ROOT_DIR, 'web', 'images', 'favicon.ico')) tray_icon.icon = image # NOTE: Open the application. "%(app_name)s" = "RetroArcher". Do not translate "%(app_name)s". @@ -197,7 +197,7 @@ def tray_run_threaded() -> bool: -------- tray_initialize : This function first, initializes the tray icon using ``tray_initialize()``. tray_run : Then, ``tray_run`` is executed in a thread. - pyra.threads.run_in_thread : Run a method within a thread. + common.threads.run_in_thread : Run a method within a thread. Examples -------- @@ -243,26 +243,26 @@ def tray_quit(): """ Shutdown RetroArcher. - Set the 'pyra.SIGNAL' variable to 'shutdown'. + Set the 'common.SIGNAL' variable to 'shutdown'. Examples -------- >>> tray_quit() """ - pyra.SIGNAL = 'shutdown' + common.SIGNAL = 'shutdown' def tray_restart(): """ Restart RetroArcher. - Set the 'pyra.SIGNAL' variable to 'restart'. + Set the 'common.SIGNAL' variable to 'restart'. Examples -------- >>> tray_restart() """ - pyra.SIGNAL = 'restart' + common.SIGNAL = 'restart' def tray_run(): @@ -309,8 +309,7 @@ def open_webapp() -> bool: >>> open_webapp() True """ - url = f"http://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" - return helpers.open_url_in_browser(url=url) + return helpers.open_url_in_browser(url=webapp.URL) def github_releases(): diff --git a/pyra/webapp.py b/src/common/webapp.py similarity index 69% rename from pyra/webapp.py rename to src/common/webapp.py index 713127322..2aaad6beb 100644 --- a/pyra/webapp.py +++ b/src/common/webapp.py @@ -1,10 +1,10 @@ """ -.. - webapp.py +src/common/webapp.py Responsible for serving the webapp. """ # standard imports +import json import os from typing import Optional @@ -12,25 +12,47 @@ from flask import Flask, Response from flask import jsonify, render_template as flask_render_template, request, send_from_directory from flask_babel import Babel +from flask_wtf import CSRFProtect +import polib +from werkzeug.utils import secure_filename # local imports -import pyra -from pyra import config -from pyra import hardware -from pyra.definitions import Paths -from pyra import locales -from pyra import logger +import common +from common import config +from common import crypto +from common import hardware +from common.definitions import Paths +from common import locales +from common import logger + +# variables +URL_SCHEME = None +URL = None # localization _ = locales.get_text() +responses = { + 500: Response(response='Internal Server Error', status=500, mimetype='text/plain') +} + +# mime type map +mime_type_map = { + 'gif': 'image/gif', + 'ico': 'image/vnd.microsoft.icon', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'svg': 'image/svg+xml', +} + # setup flask app app = Flask( import_name=__name__, root_path=os.path.join(Paths.ROOT_DIR, 'web'), static_folder=os.path.join(Paths.ROOT_DIR, 'web'), - template_folder=os.path.join(Paths.ROOT_DIR, 'web', 'templates') - ) + template_folder=os.path.join(Paths.ROOT_DIR, 'web', 'templates'), +) # remove extra lines rendered jinja templates app.jinja_env.trim_blocks = True @@ -60,6 +82,9 @@ for handler in log_handlers: app.logger.addHandler(handler) +csrf = CSRFProtect() +csrf.init_app(app) + def render_template(template_name_or_list, **context): """ @@ -85,7 +110,7 @@ def render_template(template_name_or_list, **context): -------- >>> render_template(template_name_or_list='home.html', title=_('Home')) """ - context['ui_config'] = pyra.CONFIG['User_Interface'].copy() + context['ui_config'] = common.CONFIG['User_Interface'].copy() return flask_render_template(template_name_or_list=template_name_or_list, **context) @@ -134,7 +159,7 @@ def callback_dashboard() -> Response: See Also -------- - pyra.hardware.chart_data : This function sets up the data in the proper format. + common.hardware.chart_data : This function sets up the data in the proper format. Examples -------- @@ -177,7 +202,7 @@ def settings(configuration_spec: Optional[str]) -> render_template: -------- >>> settings() """ - config_settings = pyra.CONFIG + config_settings = config.decode_config(common.CONFIG) if not configuration_spec: config_spec = config._CONFIG_SPEC_DICT @@ -221,30 +246,50 @@ def docs(filename) -> send_from_directory: return send_from_directory(directory=os.path.join(Paths.DOCS_DIR), path=filename) -@app.route('/favicon.ico') -def favicon() -> send_from_directory: +@app.route( + '/favicon.ico', + defaults={'img': 'favicon.ico'}, + methods=['GET'], +) +@app.route("/images/", methods=["GET"]) +def image(img: str) -> send_from_directory: """ - Serve the favicon.ico file. + Get image from static/images directory. - .. todo:: This documentation needs to be improved. + Serve images from the static/images directory. + + Parameters + ---------- + img : str + The image to return. Returns ------- flask.send_from_directory - The ico file. + The image. Notes ----- The following routes trigger this function. - `/favicon.ico` + - `/favicon.ico` + - `/images/` Examples -------- - >>> favicon() + >>> image('favicon.ico') """ - return send_from_directory(directory=os.path.join(app.static_folder, 'images'), - path='retroarcher.ico', mimetype='image/vnd.microsoft.icon') + directory = os.path.join(app.static_folder, 'images') + filename = os.path.basename(secure_filename(filename=img)) # sanitize the input + + if os.path.isfile(os.path.join(directory, filename)): + file_extension = filename.rsplit('.', 1)[-1] + if file_extension in mime_type_map: + return send_from_directory(directory=directory, path=filename, mimetype=mime_type_map[file_extension]) + else: + return Response(response='Invalid file type', status=400, mimetype='text/plain') + else: + return Response(response='Image not found', status=404, mimetype='text/plain') @app.route('/status') @@ -273,7 +318,7 @@ def test_logger() -> str: """ Test logging functions. - Check `./logs/pyra.webapp.log` for output. + Check `./logs/common.webapp.log` for output. Returns ------- @@ -302,7 +347,7 @@ def test_logger() -> str: @app.route('/api/settings/') def api_settings(configuration_spec: Optional[str]) -> Response: """ - Get current settings or save changes to settings from web ui. + Get current settings or save changes to settings from the web ui. This endpoint accepts a `GET` or `POST` request. A `GET` request will return the current settings. A `POST` request will process the data passed in and return the results of processing. @@ -320,7 +365,7 @@ def api_settings(configuration_spec: Optional[str]) -> Response: Examples -------- - >>> callback_dashboard() + >>> api_settings() """ if not configuration_spec: @@ -342,6 +387,7 @@ def api_settings(configuration_spec: Optional[str]) -> Response: } data = request.form + _config = config.decode_config(common.CONFIG) for option, value in data.items(): split_option = option.split('|', 1) key = split_option[0] @@ -351,7 +397,7 @@ def api_settings(configuration_spec: Optional[str]) -> Response: # get the original value try: - og_value = config.CONFIG[key][setting] + og_value = _config[key][setting] except KeyError: og_value = '' finally: @@ -361,6 +407,8 @@ def api_settings(configuration_spec: Optional[str]) -> Response: value = float(value) if setting_type == 'integer': value = int(value) + if config.is_masked_field(section=key, key=setting): + value = config.encode_value(value) if og_value != value: # setting changed, get the on change command @@ -395,19 +443,68 @@ def start_webapp(): Examples -------- >>> start_webapp() - * Serving Flask app 'pyra.webapp' (lazy loading) + * Serving Flask app 'common.webapp' (lazy loading) ... - * Running on http://.../ (Press CTRL+C to quit) + * Running on https://.../ (Press CTRL+C to quit) - >>> from pyra import webapp, threads + >>> from common import webapp, threads >>> threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() - * Serving Flask app 'pyra.webapp' (lazy loading) + * Serving Flask app 'common.webapp' (lazy loading) ... - * Running on http://.../ (Press CTRL+C to quit) + * Running on https://.../ (Press CTRL+C to quit) """ + global URL, URL_SCHEME + URL_SCHEME = 'https' if config.CONFIG['Network']['SSL'] else 'http' + URL = f"{URL_SCHEME}://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" + + if config.CONFIG['Network']['SSL']: + cert_file, key_file = crypto.initialize_certificate() + else: + cert_file = key_file = None + app.run( host=config.CONFIG['Network']['HTTP_HOST'], port=config.CONFIG['Network']['HTTP_PORT'], - debug=pyra.DEV, + debug=common.DEV, + ssl_context=(cert_file, key_file) if config.CONFIG['Network']['SSL'] else None, use_reloader=False # reloader doesn't work when running in a separate thread ) + + +@app.route("/translations", methods=["GET"]) +def translations() -> Response: + """ + Serve the translations. + + Gets the user's locale and serves the translations for the webapp. + + Returns + ------- + Response + The translations. + + Examples + -------- + >>> translations() + """ + locale = locales.get_locale() + + po_files = [ + f'{Paths.LOCALE_DIR}/{locale}/LC_MESSAGES/retroarcher.po', # selected locale + f'{Paths.LOCALE_DIR}/retroarcher.po', # fallback to default domain + ] + + for po_file in po_files: + if os.path.isfile(po_file): + po = polib.pofile(po_file) + + # convert the po to json + data = dict() + for entry in po: + if entry.msgid: + data[entry.msgid] = entry.msgstr + app.logger.debug(f'Translation: {entry.msgid} -> {entry.msgstr}') + + return Response(response=json.dumps(data), + status=200, + mimetype='application/json') diff --git a/retroarcher.py b/src/retroarcher.py similarity index 80% rename from retroarcher.py rename to src/retroarcher.py index 0b3edac8f..1ab0cd511 100644 --- a/retroarcher.py +++ b/src/retroarcher.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 """ -.. - retroarcher.py +src/retroarcher.py Responsible for starting RetroArcher. """ -# future imports -from __future__ import annotations - # standard imports import argparse import os @@ -16,21 +12,21 @@ from typing import Union # local imports -import pyra -from pyra import config -from pyra import definitions -from pyra import helpers -from pyra import locales -from pyra import logger -from pyra import threads +import common +from common import config +from common import definitions +from common import helpers +from common import locales +from common import logger +from common import threads -py_name = 'pyra' +app_name = 'common' # locales _ = locales.get_text() # get logger -log = logger.get_logger(name=py_name) +log = logger.get_logger(name=app_name) class IntRange(object): @@ -151,18 +147,18 @@ def main(): if args.config: config_file = args.config else: - config_file = os.path.join(definitions.Paths.DATA_DIR, definitions.Files.CONFIG) + config_file = os.path.join(definitions.Paths.CONFIG_DIR, definitions.Files.CONFIG) if args.debug: - pyra.DEBUG = True + common.DEBUG = True if args.dev: - pyra.DEV = True + common.DEV = True if args.quiet: - pyra.QUIET = True + common.QUIET = True # initialize retroarcher # logging should not occur until after initialize # any submodules that require translations need to be imported after config is initialize - pyra.initialize(config_file=config_file) + common.initialize(config_file=config_file) if args.config: log.info(msg=f"RetroArcher is using custom config file: {config_file}.") @@ -178,7 +174,7 @@ def main(): config.CONFIG.write() if config.CONFIG['General']['SYSTEM_TRAY']: - from pyra import tray_icon # submodule requires translations so importing after initialization + from common import tray_icon # submodule requires translations so importing after initialization # also do not import if not required by config options tray_icon.tray_run_threaded() @@ -188,13 +184,12 @@ def main(): pyi_splash.update_text("Starting the webapp") time.sleep(3) # show splash screen for a min of 3 seconds pyi_splash.close() # close the splash screen - from pyra import webapp # import at use due to translations + from common import webapp # import at use due to translations threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() # this should be after starting flask app if config.CONFIG['General']['LAUNCH_BROWSER'] and not args.nolaunch: - url = f"http://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" - helpers.open_url_in_browser(url=url) + helpers.open_url_in_browser(url=webapp.URL) wait() # wait for signal @@ -203,38 +198,38 @@ def wait(): """ Wait for signal. - Endlessly loop while `pyra.SIGNAL = None`. - If `pyra.SIGNAL` is changed to `shutdown` or `restart` `pyra.stop()` will be executed. - If KeyboardInterrupt signal is detected `pyra.stop()` will be executed. + Endlessly loop while `common.SIGNAL = None`. + If `common.SIGNAL` is changed to `shutdown` or `restart` `common.stop()` will be executed. + If KeyboardInterrupt signal is detected `common.stop()` will be executed. Examples -------- >>> wait() """ - from pyra import hardware # submodule requires translations so importing after initialization + from common import hardware # submodule requires translations so importing after initialization log.info("RetroArcher is ready!") while True: # wait endlessly for a signal - if not pyra.SIGNAL: + if not common.SIGNAL: hardware.update() # update dashboard resource values try: time.sleep(1) except KeyboardInterrupt: - pyra.SIGNAL = 'shutdown' + common.SIGNAL = 'shutdown' else: - log.info(f'Received signal: {pyra.SIGNAL}') + log.info(f'Received signal: {common.SIGNAL}') - if pyra.SIGNAL == 'shutdown': - pyra.stop() - elif pyra.SIGNAL == 'restart': - pyra.stop(restart=True) + if common.SIGNAL == 'shutdown': + common.stop() + elif common.SIGNAL == 'restart': + common.stop(restart=True) else: log.error('Unknown signal. Shutting down...') - pyra.stop() + common.stop() break -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main() diff --git a/tests/conftest.py b/tests/conftest.py index f65567ad7..d1e233baa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,26 +1,32 @@ """ -.. - conftest.py +tests/conftest.py Fixtures for pytest. """ # standard imports import os +import sys # lib imports import pytest -# local imports -import pyra -from pyra import config -from pyra import definitions -from pyra import webapp +pytest.root_dir = root_dir = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +pytest.src_dir = src_dir = os.path.join(root_dir, 'src') + +if os.path.isdir(src_dir): # avoid flake8 E402 warning + sys.path.insert(0, src_dir) + + # local imports + import common + from common import config + from common import definitions + from common import webapp @pytest.fixture(scope='function') def test_config_file(): """Set a test config file path""" - test_config_file = os.path.join(definitions.Paths.DATA_DIR, 'test_config.ini') # use a dummy ini file + test_config_file = os.path.join(definitions.Paths.CONFIG_DIR, 'test_config.ini') # use a dummy ini file yield test_config_file @@ -36,17 +42,17 @@ def test_config_object(test_config_file): @pytest.fixture(scope='function') -def test_pyra_init(test_config_file): - test_pyra_init = pyra.initialize(config_file=test_config_file) +def test_common_init(test_config_file): + test_common_init = common.initialize(config_file=test_config_file) - yield test_pyra_init + yield test_common_init - pyra._INITIALIZED = False - pyra.SIGNAL = 'shutdown' + common._INITIALIZED = False + common.SIGNAL = 'shutdown' @pytest.fixture(scope='function') -def test_client(test_pyra_init): +def test_client(test_common_init): """Create a test client for testing webapp endpoints""" app = webapp.app app.testing = True diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py index 57ea98ee5..9e411c377 100644 --- a/tests/functional/test_webapp.py +++ b/tests/functional/test_webapp.py @@ -2,7 +2,7 @@ .. test_webapp.py -Functional tests for pyra.webapp. +Functional tests for common.webapp. """ # standard imports import json diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d8052f018..b5dbaf5f9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2,7 +2,7 @@ .. test_config.py -Unit tests for pyra.config. +Unit tests for common.config. """ # standard imports import time @@ -12,7 +12,7 @@ import pytest # local imports -from pyra import config +from common import config def test_create_config(test_config_file): @@ -45,7 +45,7 @@ def test_convert_config(): def test_on_change_tray_toggle(): """Tests the on_change_tray_toggle function""" - from pyra import tray_icon + from common import tray_icon if not tray_icon.icon_supported: pytest.skip("tray icon not supported") diff --git a/tests/unit/test_definitions.py b/tests/unit/test_definitions.py index d8cab3870..3aae90a4f 100644 --- a/tests/unit/test_definitions.py +++ b/tests/unit/test_definitions.py @@ -2,10 +2,10 @@ .. test_definitions.py -Unit tests for pyra.definitions.py. +Unit tests for common.definitions.py. """ # local imports -from pyra import definitions +from common import definitions def test_names(): @@ -52,7 +52,8 @@ def test_paths(): """Tests Paths class""" paths = definitions.Paths - assert paths.PYRA_DIR + assert paths.COMMON_DIR + assert paths.SRC_DIR assert paths.ROOT_DIR assert paths.DATA_DIR assert paths.BINARY_PATH diff --git a/tests/unit/test_hardware.py b/tests/unit/test_hardware.py index 83d41b382..9e397b1fb 100644 --- a/tests/unit/test_hardware.py +++ b/tests/unit/test_hardware.py @@ -2,13 +2,13 @@ .. test_hardware.py -Unit tests for pyra.hardware.py. +Unit tests for common.hardware.py. """ # lib imports import pytest # local imports -from pyra import hardware +from common import hardware def test_update(): diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 86d2b60bc..05d45c4cf 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -2,14 +2,14 @@ .. test_helpers.py -Unit tests for pyra.helpers.py. +Unit tests for common.helpers.py. """ # standard imports import datetime import logging # local imports -from pyra import helpers +from common import helpers def test_check_folder_writeable(): @@ -22,7 +22,7 @@ def test_check_folder_writeable(): def test_get_logger(): """Test that logger object can be created""" - test_logger = helpers.get_logger(name='pyra') + test_logger = helpers.get_logger(name='common') assert isinstance(test_logger, logging.Logger) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 69ae5f979..7879328f4 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -2,15 +2,15 @@ .. test_init.py -Unit tests for pyra.__init__.py. +Unit tests for common.__init__.py. """ import pytest -def test_initialize(test_pyra_init): +def test_initialize(test_common_init): """Tests initializing retroarcher""" - print(test_pyra_init) - assert test_pyra_init + print(test_common_init) + assert test_common_init @pytest.mark.skip(reason="impossible to test as it has a sys.exit() event and won't actually return") diff --git a/tests/unit/test_locales.py b/tests/unit/test_locales.py index e150018f5..23d704bc7 100644 --- a/tests/unit/test_locales.py +++ b/tests/unit/test_locales.py @@ -2,13 +2,13 @@ .. test_locales.py -Unit tests for pyra.locales.py. +Unit tests for common.locales.py. """ # standard imports import inspect # local imports -from pyra import locales +from common import locales def test_get_all_locales(): diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index b9834448c..6d42cba87 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -2,13 +2,13 @@ .. test_logger.py -Unit tests for pyra.logger.py. +Unit tests for common.logger.py. """ # standard imports import logging # local imports -from pyra import logger +from common import logger def test_blacklist_config(): @@ -33,7 +33,7 @@ def test_init_multiprocessing(): def test_get_logger(): """Test that logger object can be created""" - log = logger.get_logger(name='pyra') + log = logger.get_logger(name='common') assert isinstance(log, logging.Logger) @@ -44,7 +44,7 @@ def test_setup_loggers(): def test_init_logger(): """Test that logger can be initialized""" - log = logger.init_logger(log_name='pyra') + log = logger.init_logger(log_name='common') assert isinstance(log, logging.Logger) diff --git a/tests/unit/test_threads.py b/tests/unit/test_threads.py index 6adf05afa..53fa7fcea 100644 --- a/tests/unit/test_threads.py +++ b/tests/unit/test_threads.py @@ -2,10 +2,10 @@ .. test_threads.py -Unit tests for pyra.threads. +Unit tests for common.threads. """ # local imports -from pyra import threads +from common import threads def test_run_in_thread(): diff --git a/tests/unit/test_tray_icon.py b/tests/unit/test_tray_icon.py index 22124b671..a1af84351 100644 --- a/tests/unit/test_tray_icon.py +++ b/tests/unit/test_tray_icon.py @@ -2,7 +2,7 @@ .. test_tray_icon.py -Unit tests for pyra.tray_icon. +Unit tests for common.tray_icon. """ # standard imports import time @@ -11,8 +11,8 @@ import pytest # local imports -import pyra -from pyra import tray_icon +import common +from common import tray_icon @pytest.fixture(scope='function') @@ -118,7 +118,7 @@ def test_tray_quit(): """Test tray_quit function""" tray_icon.tray_quit() - signal = pyra.SIGNAL + signal = common.SIGNAL assert signal == 'shutdown' @@ -127,7 +127,7 @@ def test_tray_restart(): """Test tray_restart function""" tray_icon.tray_restart() - signal = pyra.SIGNAL + signal = common.SIGNAL assert signal == 'restart' diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py index f28952ab2..f9032f941 100644 --- a/tests/unit/test_webapp.py +++ b/tests/unit/test_webapp.py @@ -2,14 +2,14 @@ .. test_webapp.py -Unit tests for pyra.webapp. +Unit tests for common.webapp. """ # standard imports import sys # local imports -from pyra import threads -from pyra import webapp +from common import threads +from common import webapp def test_start_webapp(): diff --git a/web/images/retroarcher.ico b/web/images/favicon.ico similarity index 100% rename from web/images/retroarcher.ico rename to web/images/favicon.ico diff --git a/web/js/discord.js b/web/js/discord.js deleted file mode 100644 index 92c974ec7..000000000 --- a/web/js/discord.js +++ /dev/null @@ -1,47 +0,0 @@ -// this script requires jquery to be loaded on the source page, like so... -// -function getRandomIntInclusive(min, max) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function randomQuote(quote, crate) { - let the_quote = null - if (quote['quote_safe']) { - the_quote = quote['quote_safe']; - } else { - the_quote = quote['quote']; - } - - crate.notify(the_quote) -} - -let quote = null - -// get random video game quotes -$.ajax({ - url: `https://app.lizardbyte.dev/uno/random-quotes/games.json`, - type: "GET", - dataType: "json", - success: function (result) { - let quote_index = getRandomIntInclusive(0, result.length - 1); - quote = result[quote_index] - } -}); - -// use Jquery to load other javascript -$.getScript('https://cdn.jsdelivr.net/npm/@widgetbot/crate@3', function() -{ - const crate = new Crate({ - server: '804382334370578482', - channel: '804383092822900797', - defer: false, - }) - - let sleep = ms => { - return new Promise(resolve => setTimeout(resolve, ms)); - }; - // sleep for 1 second - sleep(420000).then(() => {randomQuote(quote, crate)}) -}); diff --git a/web/js/translations.js b/web/js/translations.js new file mode 100644 index 000000000..fe1a239bf --- /dev/null +++ b/web/js/translations.js @@ -0,0 +1,32 @@ +let translations = null + +let getTranslation = function(string) { + // download translations + if (translations === null) { + $.ajax({ + async: false, + url: "/translations/", + type: "GET", + dataType: "json", + success: function (result) { + translations = result + } + }) + } + + if (translations) { + try { + if (translations[string]) { + return translations[string] + } else { + return string + } + } catch (err) { + return string + } + } + else { + // could not download translations + return string + } +} diff --git a/web/templates/base.html b/web/templates/base.html index b51a04de2..4cfbff052 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -30,7 +30,6 @@ {% block head %}{% endblock head %} - @@ -54,7 +53,7 @@ - + diff --git a/web/templates/navbar.html b/web/templates/navbar.html index a81716760..4b182b50e 100644 --- a/web/templates/navbar.html +++ b/web/templates/navbar.html @@ -24,17 +24,17 @@ @@ -45,19 +45,19 @@ @@ -68,15 +68,15 @@ @@ -105,4 +105,3 @@ -